Compare commits

..
Author SHA1 Message Date
Will Miao 1a6b0f78a3 chore(libraries): deprecate multi-library support ahead of removal
Warn at startup (log + dismissible startup message) when settings.json
contains more than one library, marking the feature as deprecated while
keeping it fully functional. Removal is planned for one of the next
releases, at which point all libraries' folder paths will be merged into
a single library. Multiple folder roots within one library remain fully
supported and unaffected.

- SettingsManager: add _warn_if_multi_library() invoked from
  _migrate_to_library_registry
- Settings UI: label the Active Library selector as deprecated and note
  the removal plan in its help text (en.json)
- Docs: add deprecation banners to multi_library_design.md and
  library-switching.md
- Remove dead code: SettingsManager.update_active_library_paths had no
  call sites
2026-10-09 11:10:04 +08:00
Will Miao 18f3208468 fix(civitai): surface missing-model diagnostics and anchor image-page URL parsing
- Return the ResourceNotFoundError detail from get_civitai_versions and
  get_model_versions_status instead of a bare "Model not found", and let
  the download modal display the server-provided message
- Anchor extract_civitai_image_id / extractCivitaiImageId to
  ^/images/<id> so API paths and partial numeric IDs are rejected, same
  as the model-page parsing fix in #1140
2026-10-09 08:12:56 +08:00
Eric-Yeh 01ffda7303 fix(civitai): anchor model-page URL parsing and improve missing-model diagnostics (#1140) 2026-10-09 08:07:00 +08:00
Will Miao fad17decdf fix(download): scope the preflight cooldown gate to the download source
The pre-flight rate-limit gate checked civarchive.com for every download,
so a cooldown armed by bulk metadata fetches blocked plain CivitAI
downloads for the whole window. Only CivArchive-sourced downloads consult
civarchive.com now.
2026-10-08 21:51:43 +08:00
Will Miao acefe1a348 fix(metadata): fail loudly when the sidecar write cannot land
update_model_metadata ignored the save_metadata result, so a fetch on a
drive that went offline mid-run was counted as success: the cache got
the fresh CivitAI payload while the durable sidecar never landed, and
every later fetch skipped the model as already fetched.

The save result now raises MetadataPersistError. In the bulk fetch path
this fails the item (reported in the summary, cache untouched, model
stays eligible for the next run); relink surfaces an honest 500.

Also corrects the stale known-limitation note in the scoped-scan plan:
a startup-offline root has been listed and re-admitted since Wave 6.
2026-10-08 07:55:47 +08:00
Will Miao 95acd6308a i18n(metadata): translate the fetch summary 'Drive Offline' stat
Reuses each locale's established rootOffline rendering from the scoped
scan feature (§2 'Scoped scan and root availability').
2026-10-08 07:49:04 +08:00
Will Miao 3468686e5f feat(metadata): skip models on offline roots during bulk fetch (#1108)
Bulk metadata refresh picked candidates purely from cache state, so the
entries the scoped scan deliberately keeps for unreachable drives were
fetched anyway — burning CivitAI rate limit on sidecar/preview writes
that cannot land.

- Filter the fetch queue by root reachability (live check via
  describe_model_roots), covering both startup-offline and mid-session
  drive-off; probing failures degrade to no filtering
- Report offline_skipped / offline_roots in the WS terminal payloads,
  the HTTP result and the completion message
- Show a "Drive Offline" stat card in the fetch summary modal when
  any model was skipped this way
2026-10-08 07:47:54 +08:00
Will Miao 842ffb1df8 i18n(sidebar): translate the recipes-folder protection strings 2026-10-07 19:39:20 +08:00
Will Miao 1805519f4f fix(sidebar): keep the recipes storage dir out of folder management
The default recipes dir (<first lora root>/recipes) is auto-created by the
recipe scanner and recorded by the model-root walks as an empty folder, so
it showed up in the folder sidebar — and since recipe files are not model
weight files, the folder delete endpoint's model_count check let a
right-click delete wipe the whole recipe library via shutil.rmtree.

- scan walks (full scan, reconcile, all-folders backfill) no longer descend
  into the effective recipes dir, and get_all_folders() filters it out of
  snapshots persisted before the exclusion existed
- delete_folder/rename_folder refuse the recipes dir and any ancestor of
  it with code "protected"
- the delete modal renders a protected state (confirm hidden, per-row
  checkbox disabled) instead of a deletable 'no models' prediction
- RecipeScanner.recipes_dir now shares the side-effect-free resolver in
  py/utils/recipes_paths.py
2026-10-07 19:38:28 +08:00
Will Miao f4d0228d6b feat(scanner): report and re-admit model roots that are unavailable (#1108)
`Config._dedupe_existing_paths()` drops roots whose directory does not exist
when the root list is built, so a drive that is switched off while LM starts was
not "an offline root" — it was not a root at all. The Refresh ▾ menu had no row
for it in either state, a full refresh reported nothing (its cached entries
survived only because they fall outside every configured prefix), and plugging
the drive back in changed nothing until the next restart.

* `Config` now records the configured (existence-unfiltered) paths per model
  type — from ComfyUI's list in plugin mode, from the active library snapshot in
  standalone mode, where the host mock filters non-existent paths itself — and
  exposes them through `configured_roots_for()`.
* `ModelScanner.describe_model_roots()` appends configured-but-unavailable roots
  with a live `reachable`, an `available` flag and their cached entry count, so
  `/roots` (and therefore the menu) offers a row in both states: greyed while the
  directory is missing, normal and clickable once it is back.
* `_reconcile_cache()` reports them as `skipped_roots` / `unavailable_paths` with
  reason `root_unavailable` and counts their cached entries in `kept_unreachable`,
  which is what makes the "N models kept" toast appear in the startup-offline
  case. Report-only: they stay outside the scan scope, so nothing about pruning
  changes. A root-scoped scan stays quiet about roots it was not asked about, and
  a folder-scoped scan only mentions a missing root that holds cached entries of
  that folder.
* `Config.admit_configured_roots()` re-runs the per-type prepare helpers against
  the configured paths and APPENDS what is readable now (plus the
  checkpoint/unet/other side maps), then refreshes the preview allowlist, so a
  drive plugged in mid-session can be scanned without restarting. Append-only and
  order-preserving by design: removing a root mid-session would let a later
  settings save persist the loss, and re-sorting would move `*_roots[0]`, which
  derives the recipes directory and the usage-stats file location. `_roots[0]`
  never moves.
* `/roots` and `/scan` admit first, so `describe_model_roots()` stays a pure read
  and a caller that never opens the menu (the browser extension) still gets a
  drive that came back.

Verified on the sandbox with drive-Z switched off before startup: `/roots`
reports `drive-Z reachable=false available=false models=60`, a full refresh
returns `skipped_roots=[drive-Z root_unavailable]`,
`unavailable_paths=[{... kept: 60}]`, `kept_unreachable=60`, and all 432 models
stay cached. Renaming the drive back while the server runs admits it
(`available=true`) and `GET /scan?roots=<drive-Z>` walks it with no restart.
3714 passed, 7 skipped; frontend 1495 passed (149 files); vue widgets 96 passed.
2026-10-07 16:38:38 +08:00
Will Miao 08a8e54d87 fix(config): keep unavailable model roots in the saved library paths
Starting ComfyUI with an external drive switched off erased that drive's path
from `settings.json`. `Config._dedupe_existing_paths()` drops paths that do not
exist at the moment the root list is built, and `save_folder_paths_to_settings()`
(plugin mode, called from `Config.__init__`) persisted `list(self.loras_roots)`
through `upsert_library(folder_paths=...)`, which REPLACES the library's paths.
One boot with the drive off was therefore enough to lose the configuration, not
just to hide the root — the same applied to checkpoints/unet/embeddings and the
other-model keys.

* `Config` now records the *configured* (existence-unfiltered) primary paths per
  model type while building the live lists, and the startup sync persists those.
  A path the host (plugin mode) or the library (standalone mode) no longer
  configures is still dropped, so removing a path keeps working.
* In standalone mode the host mock (`MockFolderPaths.get_folder_paths`) already
  filters non-existent paths out of settings.json, so the active library snapshot
  is the only record of what the user configured; `_remember_library_configured_paths()`
  reads it there and leaves plugin mode to ComfyUI's own unfiltered list.
* `_resolve_valid_default_root()` receives the configured paths in
  `allowed_paths`, so a `default_*_root` on a switched-off drive is no longer
  "repaired" onto another root.
* `_append_new_paths()` is the shared append-only union (order preserved, case
  insensitive) used by the sync.

Verified: a new config test builds the plugin-mode sync with one switched-off
drive, one readable root and one path the host dropped — the switched-off path
and the default root that points at it survive, the dropped path does not, and
the live `loras_roots` still holds only the readable root. tests/config 64 passed.
2026-10-07 16:35:29 +08:00
Will Miao 0941f99226 i18n(sidebar): translate the folder-scan strings
The sidebar's "Scan this folder" entry and its vanished-folder warning were the
last 2 [TODO: Translate] keys, so grep -c "TODO: Translate" locales/*.json is 0
everywhere again.

  scanFolder              zh-CN 扫描此文件夹 · zh-TW 掃描此資料夾 · ja このフォルダをスキャン
                          ko 이 폴더 스캔 · fr Analyser ce dossier · de Diesen Ordner scannen
                          es Escanear esta carpeta · ru Сканировать эту папку · he סרוק תיקייה זו
  scanFolderResult.missing
                          reused verbatim from each locale's existing
                          renameFolderResult.missing / deleteFolderResult.missing — the situation
                          is identical ("the folder vanished before the action ran"), so a third
                          variant of the same sentence would only be a translation drift risk.

The label follows each locale's "Check for updates in this folder" phrasing
(检查此文件夹的更新 → 扫描此文件夹, Vérifier les mises à jour dans ce dossier → Analyser ce
dossier) so the two refresh entries in the same menu read as a pair.
docs/i18n-translation-guidelines.md gains the two rows in "Scoped scan and root
availability" plus the updated status note.

Verified: python scripts/sync_translation_keys.py --dry-run reports no pending
changes, pytest tests/i18n 20 passed, frontend 1495 passed.
2026-10-07 15:26:08 +08:00
Will Miao 12930ce78c feat(sidebar): scan a single folder from the folder context menu (#1108)
The Refresh menu can scope a scan to one model root, but the folder a user is
looking at lives in the sidebar's unified tree, which merges every root into one
relative-path namespace. "Scan this folder" therefore addresses the folder, not a
root: the backend walks that relative path under every root that holds it, which
is also what makes the action safe while another drive is switched off.

Backend:
* GET /scan accepts `folder=<rel>` (alone or with `roots=`), rejected together
  with full_rebuild=true like the roots parameter. Validation reuses
  normalize_relative_folder(), extracted to module scope from ModelMoveService so
  the folder operations and the scan endpoint reject the same input (absolute
  paths, drive letters, `..` climbing) instead of each carrying its own copy.
* The reconcile summary carries `scope_label` (the folder) for a folder scope, so
  the result toast names the folder the user clicked instead of the roots it
  happens to live under; the completed WS payload carries it too.
* `folder` is a scope prefix exactly like a root: only that subtree is re-read or
  pruned, and an unreachable root keeps the entries that fall inside it.

Frontend:
* The sidebar folder context menu gains "Scan this folder" above "Check for
  updates in this folder" (they share the refresh divider); the entry is gated by
  the same supportsFolderManagement flag as the other folder operations.
* SidebarManager.scanFolder() resolves the node through the existing
  _resolveFolderCandidates() before doing anything: a folder no root holds any
  more explains itself ("no longer exists on disk") instead of scanning nothing,
  and an unresolvable multi-root node is refused rather than guessed.
* PageControls.refreshModels() and BaseModelApi.refreshModels() forward the folder
  scope, and _showRefreshSummary() prefers scope_label over the walked roots.

Verified live on the three-root sandbox with one drive switched off:
GET /scan?folder=pack000 walks drive-G and drive-Y, reports scope_label=pack000,
keeps drive-Z's 6 entries under that folder (kept_unreachable=6) and leaves all
420 models cached. 3704 passed, 7 skipped; frontend 1495 passed (150 files); vue
widgets 96 passed. The 2 new sidebar keys are [TODO: Translate] placeholders
pending the feature owner's go-ahead.
2026-10-07 15:15:35 +08:00
Will Miao bd184559bf i18n(scanner): translate the scoped-scan strings
The scoped-scan feature left exactly 6 [TODO: Translate] keys behind, so this
is the whole pending set for all 9 locales. Renderings reuse the established
nouns (folder: zh-CN 文件夹 / ja フォルダ / ru папка / he תיקייה; "model" counts
from sidebar.deleteFolderModal) and introduce one new term, "Offline" for a
configured root that cannot be read right now:

  scopeSection        zh-CN 只扫描一个文件夹 · ja フォルダを 1 つだけスキャン
                      fr Analyser un seul dossier · ru Сканировать только одну папку
  rootOffline         zh-CN 离线 · ja オフライン · fr Hors ligne · ru Недоступен
  rootModels          zh-CN {count} 个模型 · ko 모델 {count}개 · he {count} מודלים
  refreshCompleteScoped / refreshKeptUnreachable / scanRootUnreachable
                      (counts and {scope}/{paths} stay verbatim)

Every placeholder set matches en.json, fr keeps the typographic apostrophe, and
the three CJK locales keep full-width punctuation while ko uses ASCII like the
rest of that file. docs/i18n-translation-guidelines.md gains the
"Scoped scan and root availability" terminology table plus the updated status
note, so the "no remaining placeholders" claim holds again.

Verified: python scripts/sync_translation_keys.py --dry-run reports no pending
changes, pytest tests/i18n 20 passed, and grep -c "TODO: Translate"
locales/*.json is 0 everywhere.
2026-10-07 15:04:16 +08:00
Will Miao 470d85cca6 feat(scanner): scan a single root and keep unreachable entries (#1108)
Refreshing had no way to say "scan only this drive": a user with three external
drives had to spin all of them up for every refresh, and switching a drive off
made the next refresh treat its whole library as deleted (rows pruned from the
memory cache and the SQLite cache, preview_url stripped on the next scroll).

Backend (py/services/model_scanner.py, py/config.py):
* ReconcileScope(roots, folder) + _reconcile_cache(scope=...): files inside the
  scope reconcile normally, everything outside is neither re-read nor removed.
  The folder half is plumbing for the sidebar entry in the next change.
* Path-level pruning guard: cached entries under a path this walk could not
  read are kept and reported instead of removed. Sources: a configured root that
  is not reachable (drive switched off while LM runs), a directory os.walk
  failed to enter (permissions / I/O error / Windows junction to an offline
  drive), and a known first-level symlink whose target is gone
  (Config.iter_path_mappings()).
* The recorded folder list is unioned instead of replaced whenever the scan did
  not verify every root, so a scoped scan cannot empty the sidebar.
* _reconcile_cache returns a summary (added / removed / repaired /
  scanned_roots / skipped_roots / unavailable_paths / kept_unreachable),
  exposed as ModelScanner.last_reconcile_summary, returned by
  BaseModelService.scan_models() and broadcast in the completed WS payload.
* _root_display_labels(): set-aware labels ("G: loras", "usb/loras") grown
  leftwards with real parent segments until unique, shared by the walk-progress
  line and the roots API.
* GET /scan accepts repeated `roots` (400 for unknown roots, 400 combined with
  full_rebuild=true); GET /roots gains root_details (label / reachable / cached
  count) while `roots` stays a plain path list for existing callers.
* serve_preview: a 404 no longer clears the cached preview_url when the file's
  own directory is unreachable - browsing the grid with a drive off used to
  strip preview references from the persistent cache.

Frontend:
* Refresh ▾ gains a "Scan one folder" section listing the page's roots with
  their cached counts; offline roots stay clickable and explain themselves; rows
  are wired by delegation (new static/js/components/controls/ScanScopeMenu.js).
* A scoped scan reports "Scanned <root>: N new, M removed"; a scan that kept
  entries reports "<N> models kept: <paths> not reachable".
* registerAPI() now injects the two cross-page passthroughs (fetchModelRoots and
  an argument-forwarding refreshModels) so a page facade cannot drop them: the
  first version rendered an empty menu and would have run a full refresh.
* createToastElement whitelists toast types, so a wrong `type` argument degrades
  to the info style instead of rendering an unstyled box.

Verified in a sandbox instance with three roots: a scoped scan walks only the
requested root (progress roots=0/1, 240 files); a full refresh with one root
offline reports kept_unreachable=60 and leaves all 420 models cached; /roots
reports the offline root with its cached count. 3699 passed, 7 skipped;
frontend 1488 passed (148 files); vue widgets 96 passed.
2026-10-07 15:01:26 +08:00
Will Miao 2dcaf6a30e feat(sidebar): manage folders that live under several model roots
The sidebar's folder tree merges every model root into one relative-path
namespace, but folder operations turned a node into a path by prefixing
default_*_root. A folder living under another root failed to delete with
"Folder no longer exists" (recipes under the primary lora root while
default_lora_root is the extra one), and where the same relative folder
exists in both roots the operation silently hit the other copy — 14 of the
16 top-level folders in the reporting library are shared, so guessing a root
was never safe.

Backend:
* ModelMoveService.resolve_folder() and GET /api/lm/{prefix}/resolve-folder
  answer which directories a library-relative folder maps to
  (folder_path/root/is_symlink), in scanner root order, skipping directories
  no root holds and refusing absolute or climbing paths.
* delete_folder/rename_folder tag a vanished directory with code "missing"
  so the sidebar can tell "this node is stale, refresh" from a failed
  operation.

Frontend:
* _resolveFolderCandidates() is the single place that turns a node into
  absolute paths: default root first, the old root-prefix fallback only
  while a single root is configured, and an explicit unresolved error for a
  multi-root library — nothing is guessed silently any more.
* One copy keeps the single-target modal, which now names the resolved
  absolute path. Several copies render one checkbox row per copy, each
  dry-run against the delete guard ("no models" / "contains N model
  file(s)..." / a deletion is still pending / no longer exists / symbolic
  link): a blocked copy is unticked, disabled and explained, the button
  reads "Delete N folders", every ticked copy is deleted and guarded on its
  own, and a partial failure is reported without discarding the successes.
* Rows are built once per open and only their status text is updated, so
  ticking a box no longer rebuilds the list, steals focus or resizes the
  modal mid-click; the action row keeps a fixed button width.
* Rename offers a root picker in its inline row, create inherits the
  parent's root when the parent resolves to exactly one directory, and the
  undo restores every copy a delete removed.

i18n: 26 new keys (sidebar.deleteFolderModal.*, .deleteFolderResult.*,
.renameFolderResult.*, .folderRoot.*, .folderResult.*) translated in all 9
locales, with the en wording normalized to the established "model root" noun
(it had said "library root") and the new terminology recorded in
docs/i18n-translation-guidelines.md.

Verified: pytest 3686 passed, vitest 1473 passed (86 in the folder-management
suite), pytest tests/i18n 20 passed, sync_translation_keys.py --dry-run
clean. A sandboxed standalone instance with two roots confirmed that deleting
one copy leaves the node in place, that the twin's model card survives the
purge, and that deleting both copies and undoing restores both directories.
2026-10-06 17:14:07 +08:00
Will Miao 5ebf5aeccc fix(scanner): keep folder records honest across the model roots
The recorded folder list is a union over the model roots keyed by relative
path, but remove_known_folder() dropped an entry unconditionally. Deleting
<rootA>/test in the sidebar therefore hid a "test" that <rootB> still held:
the node disappeared from the next tree load and came back after the next
scan, which reads as "the delete did not work". The same call purged cache
entries by relative folder, so removing an empty <rootA>/test2 also evicted
the model cards of <rootB>/test2 until the next scan, and
rename_known_folder() re-keyed both the recorded folder and the "folder"
field of models that never moved.

* _folders_present_on_disk() answers "which of these relative folders does
  some root still hold?" with stats off the event loop (model roots can live
  on slow network shares) and reports nothing for stand-in scanners without
  roots, which preserves their previous behaviour.
* remove_known_folder(folder, absolute_path=None) keeps the entries another
  root still owns and purges by the removed directory's absolute path when
  the caller knows it. Without a path the relative-folder filter stays as the
  documented fallback: it prunes the entry (disk-verified either way) but
  cannot tell same-named copies apart.
* rename_known_folder() re-adds the survivors of the old name and only
  touches cache entries whose file_path sits inside the renamed directory.

Tests: the two delete tests now remove the directory from disk first, which
is what the contract always assumed, plus three new scanner tests (a twin
keeps the entry, the entry goes once no root holds it, the twin's cards
survive the purge), one for the legacy fallback and one for a rename that
leaves a twin behind.
2026-10-06 17:13:57 +08:00
Will Miao a48855d576 i18n(scanner): translate the reconcile walk progress strings
The walk-progress feature left exactly one [TODO: Translate] key behind,
common.scanProgress.walkFiles ("{count} files"), so this is the whole
pending set for all 9 locales. Each rendering reuses the locale's
stages.count_models noun for "files":

  zh-CN {count} 个文件          zh-TW {count} 個檔案
  ja    {count} 件のファイル      ko    파일 {count}개
  fr    {count} fichiers        de    {count} Dateien
  es    {count} archivos        ru    {count} файл(ов)
  he    {count} קבצים

{count} arrives pre-formatted (toLocaleString), so no locale adds digit
grouping, and the string stays a bare fragment: the root labels, the
parentheses and the " | " before the ETA are composed by
static/js/api/baseModelApi.js. ru uses файл(ов) because the counter ticks
live (notEmptyMessageCount precedent).

Also document the new surface in docs/i18n-translation-guidelines.md:
a status note plus the "Scan progress (walk phase)" terminology section
recording the per-locale renderings and the fragment/composition rule.

Verified: python scripts/sync_translation_keys.py --dry-run reports no
pending changes, pytest tests/i18n 20 passed, and
grep -c "TODO: Translate" locales/*.json is 0 everywhere.
2026-10-06 12:42:08 +08:00
Will Miao 14e660e876 feat(scanner): report and parallelize the reconcile walk
A regular Refresh walked every configured root sequentially, so a full
25 TB drive delayed the roots behind it, and the dialog sat on "Checking
for changes..." at 0 % for the whole walk with no way to tell it was
working. On a cold external drive the walk itself dominates the cost, so
the fix is to overlap the drives and to show what the walk is doing.

Backend (py/services/model_scanner.py):
* Extract the per-root walk into the synchronous _walk_root_for_reconcile()
  worker and merge its results on the event loop afterwards, in configured
  root order: which business path wins a file reachable through several
  roots must not depend on the order the workers happened to finish in.
* Group roots by device (_root_device_key: drive letter on Windows, st_dev
  on POSIX) and run one worker per device. Roots sharing a device stay
  sequential, so directory claims and the overlap dedup (#871, #1041) keep
  their configured-order semantics; different devices run in parallel.
* Track walk progress per root (_ReconcileWalkTracker), weighted by each
  root's cached entry count because the real file count is only known once
  the walk ends. The bar splits into walk (0-50 %) and new-file (50-99 %)
  phases so it never jumps backwards, and the walk broadcasts files seen,
  active roots and the ETA counters.
* Replace the Windows case-insensitive fallback -- a scan of every cached
  path per miss, i.e. O(files x cached) -- with a lazily built lower-cased
  index (_CachedPathLookups, also now guarding the realpath alias index for
  worker threads), and lift the os.name == "nt" gate into the module-level
  _CASE_INSENSITIVE_PATHS so the branch is testable off Windows.
* Excluded-model membership is a set lookup instead of a list scan.

Frontend:
* render the walk phase as "Checking for changes... <roots> (N files)" with
  the ETA, and reset the ETA tracker when the stage changes: the per-file
  rate of counting files says nothing about processing them.
* add common.scanProgress.walkFiles; the other locales keep the sanctioned
  [TODO: Translate] placeholder until the translation pass.

Verified: no-change reconcile over 10k files/500 dirs 101 ms and 10k/5000
dirs 230 ms (was 93/229 ms, within noise); a two-device sandbox walk runs
both roots concurrently, names them in the progress messages and finishes
with added=15, removed=0; 3665 passed, 7 skipped; frontend 1456 passed,
vue widgets 96 passed.
2026-10-06 12:40:57 +08:00
Will Miao 8cd53c20f8 refactor(nodes): resolve dynamic inputs without inspect.stack()
The Prompt and Lora Stack Combiner nodes expose unbounded dynamic input
slots (trigger_wordsN / lora_stackN). They resolved them by having
INPUT_TYPES() return a custom lookup object, but only when the caller was
ComfyUI's get_input_info() -- detected with inspect.stack(). That frame
inspection is what the registry security scan reports as
python_anti_debugging under the obfuscated-code admin tag.

Make the lookup a dict subclass instead, so INPUT_TYPES() can always
return it:

  * /object_info (server.py) json.dumps INPUT_TYPES() directly, and a
    dict subclass serializes its stored entries -- byte-identical to the
    plain dict that was returned before.
  * input_order (list(value.keys())), validate_inputs'
    set(class_inputs["optional"]) and every other iteration still see only
    the static slots.
  * get_input_info() (graph.py) keeps resolving dynamic names through the
    overridden __contains__/__getitem__, which no longer depends on who
    the caller is.

The one behaviour change is in execution.py:get_input_data -- a dynamic
input passed as a constant rather than a link now reaches the node
instead of being silently dropped. These inputs are declared forceInput,
so the frontend only offers links; where it can happen the new behaviour
is the intended one.

Verified against ComfyUI's own consumer code: json.dumps output, keys(),
set(optional) and get_input_info() lookups all match the old behaviour,
and the two nodes no longer cross-resolve each other's slots.
3657 passed, 7 skipped.
2026-10-05 15:23:16 +08:00
Will Miao 7e57f12796 refactor(nodes): drop importlib from the pytest import fallback
The `except ImportError` branch in __init__.py exists because pytest's
Package collector walks up from tests/ while __init__.py exists, which
makes the repo root a package node and imports this file as a top-level
module (__package__ == ""). Relative imports cannot resolve there.
ComfyUI itself always loads the directory as a package, so the branch is
test-only -- verified by replaying nodes.py:load_custom_node().

`importlib.import_module("py.nodes.prompt").PromptLM` is equivalent to
`from py.nodes.prompt import PromptLM`: nothing here is lazy and nothing
avoids a cycle, so the indirection bought nothing. Removing it also drops
the python_bytecode_manipulation finding (any-code-execute +
obfuscated-code) the registry security scan reports against __init__.py.

Verified: probe during the suite shows the fallback still runs with
__package__ == "" and builds all 21 NODE_CLASS_MAPPINGS entries;
3653 passed, 7 skipped.
2026-10-05 15:18:19 +08:00
Will Miao 0e3897f09b chore(registry): trim the published archive to runtime files
The registry security scan flags a node version on ANY finding, even
severity "info", so every file in node.zip is scan surface. Dev-only
trees (tests/, docs/, scripts/, .agents/, Vue widget sources) accounted
for ~44 of the 115 findings that flagged 1.2.2-1.2.4.

comfy-cli builds the archive as `git ls-files` minus `.comfyignore`
matches, so this drops 501 of 1033 tracked files while keeping every
runtime path: py/, web/, static/, templates/, locales/,
example_workflows/, data/supporters.json, refs/, standalone.py.

Dropping vue-widgets/ is deliberate: the prebuilt bundle ships in
web/comfyui/vue-widgets/, and py/vue_widget_builder.py skips its mtime
check when src/ is absent, so end-user installs no longer risk an
npm install at startup.
2026-10-05 14:58:13 +08:00
Will Miao db50632e83 docs(update): link the filed upstream request (civitai/civitai#5384)
Filed as willmiao with the 138-word text from §13.1; the internal notes in §13.2
(write/read asymmetry, evidence table) stayed out of the issue.
2026-10-05 14:52:13 +08:00
Will Miao d141c29ced docs(update): cut the upstream request down to the ask
The first draft read as a design document for the CivitAI team: it explained how
to change toPublicPaidAccessDto, cited discountedTerms, and described how we read
prices from model pages today. Two of those do not belong in an issue.

- no implementation guidance: they know their service, and withholding prices may
  be a deliberate product decision (the code comment says pricing belongs to the
  purchase flow), so the request has to argue the need rather than the diff
- no description of our current page reading: it is our approach, it shifts the
  thread from the feature to our behaviour, and it invites an objection that has
  nothing to do with the ask

The filed text is now 138 words: ask, why (the API gives the gate and the early
access end date but not the price, which is the other half of "wait or pay now"),
one credibility clause naming the integration, and one scope concession (public or
authenticated-only). The evidence table and the write/read asymmetry stay in the
plan as internal notes, explicitly marked as not for the issue.
2026-10-05 14:42:37 +08:00
Will Miao dcc0add6b0 docs(update): draft the upstream API request for public Buzz prices
No existing civitai/civitai issue asks for this (searched paidAccess, "download
price", "buzz price API" and every open [API Feature Request]), so the draft is a
new issue using that repo's title convention.

The argument leans on how small the change is there: toPublicPaidAccessDto
already receives a PaidAccessRow carrying `terms` and `sales` and returns only
{permanent, endsAt}; discountedTerms already resolves sale prices; and the write
path already accepts the same terms via updateModelVersionPaidAccessSchema. The
reads withhold exactly what the writes accept.

It also documents why the page is not a workaround: civitai.red challenges
non-browser clients (403 for any User-Agent), while civitai.com and civitai.green
404 mature models to anonymous visitors, so mature models have no readable price
source at all.

Also marks the P4 upstream task as drafted.
2026-10-05 14:37:18 +08:00
pixelpaws 0c5bf329e9 Merge pull request #1137 from willmiao/feat/paid-model-price-tracking
feat(updates): show Buzz download prices and surface obtainability of available versions
2026-10-05 12:32:42 +08:00
Will Miao fbd2e6a2eb Merge remote-tracking branch 'origin/main' into feat/paid-model-price-tracking
# Conflicts:
#	docs/i18n-translation-guidelines.md
2026-10-05 12:28:35 +08:00
pixelpaws 15dda1f168 Merge pull request #1138 from willmiao/feat/showcase-vertical-layout
feat(ui): add optional vertical list layout for model modal showcase
2026-10-05 09:39:19 +08:00
Will Miao 977bbffa67 i18n(ja): shorten the "Free Now" badge to 無料化
無料になりました (8 characters) is a sentence, not a badge label: it sits next
to 有料 (2) and 早期アクセス (6) in the version row. 無料化 is three characters
and names the state transition this badge actually marks - it only appears when
a version's gate lapsed, never for a version that was free all along. The
tooltip keeps the full sentence.
2026-10-05 09:17:23 +08:00
Will Miao 98ba319735 i18n(update): translate the Buzz download price strings
Completes the 13 keys the obtainability feature left as [TODO: Translate], in all
9 locales: the two gate-event counts, the sale / Blue Buzz / "Free Now" / early
access end-date badge strings, and the six settings.priceTracking strings plus the
section header.

- Buzz and Blue Buzz stay as-is everywhere: they are CivitAI currency names, not
  translatable words (R3)
- register follows each file's existing norm (你 zh-CN, 您 zh-TW, Sie de, tú es,
  вы ru); fr keeps the file's ASCII apostrophe style; no full-width punctuation
  leaked into the Latin/Cyrillic/Hebrew locales
- two source fixes came with the pass: the unused settings.priceTracking.label key
  is removed (no template renders it - the toggle uses enabled/enabledHelp), and
  settings.sections.priceTracking now reads "Buzz Download Prices" so the header
  matches what the feature does: prices are displayed, nothing is tracked for
  alerts
- the locale files were edited by exact-value replacement rather than re-serialised,
  so each diff is 13 lines and the per-file indentation is untouched (R1)
2026-10-05 09:13:58 +08:00
Will Miao cbe9d58522 docs(update): the model card cannot carry a single price
Live model 958009 has 37 gated versions across 6 price points, and the owner's
own library has a model whose two early access versions end on different dates,
so an 'Update - 500 Buzz' badge would be fabricated rather than summarised. The
uniformity that made it look plausible is an artefact of floor pricing. Prices
stay in the version list; only a deadline is well defined at model level.
2026-10-05 09:05:58 +08:00
Will Miao e42d649df0 refactor(update)!: make obtainability a property of updates, not a surface
The owner could not tell from the UI what "Buzz Price Tracking" enabled, what the
"Price alert threshold" number meant, or what "Price Alerts" was alerting about.
That was not a copy problem: the implementation exposed our mechanism (a page
scrape) and our SQL predicates as the user's concepts. Two concrete defects came
from the same root:

- the alert population included versions the user already owns (neither the event
  generator nor the query filtered on is_in_library; in the owner's library 28 of
  52 gated versions were already downloaded, so most "alerts" were about files
  already on disk, which cannot become cheaper *for them*);
- a threshold-filtered state list lived in a notification surface, so an empty
  panel had three indistinguishable causes and read as a broken feature.

The information model is now the version plus ownership: cost is shown only where
a decision exists. Owned -> nothing. Not owned and free -> nothing. Not owned and
gated -> the price when it is known, `Paid` without a number when it is not, and
early access keeps its countdown because "free on <date>" decides between waiting
and paying. The numeric threshold has no place in that model: every decision is
categorical (wait / pay / skip), so the setting, the comparison and the whole
alert-state machine are gone.

- both alert-state columns are removed from the schema rather than left dead; a
  database created by an unreleased build has them dropped on open (native
  ALTER TABLE ... DROP COLUMN, guarded), which is a no-op for everyone else
- gate events are emitted only for versions the user does not have, and the
  price-drop event goes with the threshold it belonged to
- both alert endpoints, PriceAlertsHandler and the service-registry adapter field
  it needed are removed: events already reach the UI through the refresh response
- the bell tab, panel, CSS, both entry points, the unread watermark and their
  locales are removed; the setting keeps only the enable flag and the refresh
  interval and is framed as plumbing
- "Price unavailable" is replaced by `Paid`: the gate is certain from the public
  API, only the number is best-effort, and that is our plumbing, not the user's
  problem

Verified against a copy of the owner's real database: 52 gated versions ->
28 owned (now silent) + 24 the feature is actually about; the drop migration ran
and both removed endpoints 404.
2026-10-05 08:54:08 +08:00
Will Miao 156d2e5eb9 fix(ui): explain a 0 Buzz threshold in the price alerts panel
The default threshold is 0 ("only tell me when a version becomes free") and the
settings copy says so, but the panel did not: a real instance with 52 priced paid
versions and an untouched threshold showed "Nothing is under your price threshold
right now" with only a small "Alert threshold: 0 Buzz" in the corner, which reads
as a broken feature.

- the payload now carries pricedCount, so the empty state can say how many paid
  versions already have a known price
- when the threshold is 0 and prices are known, the empty state says so and
  points at Settings - Library instead of implying there is nothing to show
- the read-time threshold comparison means setting one takes effect immediately;
  measured on a copy of that instance: 0 Buzz -> 0 alerts, 100 -> 44,
  500 -> 48, 5000 -> 51
2026-10-04 20:28:57 +08:00
Will Miao 425d90a912 fix(update): decouple price capture from the metadata TTL
Found in a real instance: after enabling price tracking, a normal "Check updates"
captured exactly one price out of 718 models, so the alerts panel looked broken
while the log said the refresh completed.

Price capture only ran when the version list was re-fetched, so it inherited the
metadata TTL: with 24 h metadata and 24 h price TTLs, only the handful of models
whose metadata happened to be stale that round were ever priced.

- the cached record already carries the gate, so the price pass now runs off
  whichever version list is available (freshly fetched or stored) and applies the
  result without touching last_checked_at, so a price-only pass cannot silently
  extend the metadata TTL
- a failed attempt now satisfies the price TTL, so a mature model whose page no
  host will serve is not retried on every single update check
- an explicitly forced check re-prices within the TTL

Verified by copying a real instance's update DB into a sandbox and running a
non-forced check: bulk metadata fetches 0 (version lists entirely from cache)
while priced versions went 1 -> 20 and the panel listed 19 alerts.
2026-10-04 20:20:50 +08:00
Will Miao ec5fef512b fix(update): read model-page prices from a host that answers
End-to-end verification against the live site found the price capture broken for
a whole class of users: the civitai page hosts are not interchangeable, and the
user's civitai_host preference was silently fatal. With civitai_host=civitai.red
the update DB held zero prices even with tracking enabled.

- civitai.red refuses non-browser HTTP clients outright (Cloudflare challenge,
  403 for any User-Agent, aiohttp and httpx alike), while civitai.com and
  civitai.green answer normally for anonymously visible models and 404 for
  mature ones. An earlier manual check with curl passed on TLS fingerprint luck,
  which is why this was missed.
- get_model_prices now tries the configured host first, then the others, and
  takes the first parseable payload. The host that worked is remembered, and a
  host that refuses outright is parked for 15 minutes so a library full of
  mature models does not pay three requests each; a 404 is model-specific and
  does not park the host. Links keep using the configured host, which is where
  the user's own browser has clearance.
- Mature models still have no price source anywhere, so that is now stated
  instead of silent: price_check_attempted_at separates "tried and unreadable"
  from "never looked", gated versions show a muted "Price unavailable" badge,
  and the alerts panel reports unavailableCount.
- Failures are logged at warning level, once per host per TTL, with the
  per-host reason, instead of only at debug level.
- The recorded alternatives (internal tRPC with the user's API key, or an
  extension-assisted fetch from the user's browser) and the strengthened
  upstream ask for a public price field are documented in the plan.
2026-10-04 20:00:29 +08:00
Will Miao 7ed19c185c feat(update): add a Buzz price alerts panel to the notification bell
P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
2026-10-04 15:08:48 +08:00
Will Miao ad2402724b feat(update): track buzz prices and alert below a threshold
CivitAI's public API deliberately omits prices — paidAccess is trimmed to
{permanent, endsAt} because "pricing belongs to the purchase flow" — but the
public model page embeds the site's own model.getById result, including
paidAccess.terms, in its server-rendered payload. That is read anonymously
(no API key, no internal endpoint, no forged Origin), one request per gated
model, so only the ~2% of models that actually carry a gate pay for it.

- optional capture, off by default: price_tracking_enabled,
  price_alert_threshold_buzz (0 = alert on "became free" only) and
  price_check_ttl_hours; prices refresh on their own TTL and immediately when a
  gate changes, and a failed fetch keeps the stored price instead of blanking it
- versions that stop carrying a gate are marked free (persisted gate_lapsed_at)
  and gate transitions are reported as events on the refresh response, so a
  version already in the library can announce that it became free
- price_alert_state plus a price_drop edge event; new
  GET /api/lm/{type}/updates/price-alerts lists what is under the threshold
- versions tab shows the price (effective, with the list price struck through
  and a Blue Buzz note) and a Free Now badge; an update check toasts the
  transitions in one message
- the parser and the alerts query are unit-tested against a trimmed page
  fixture, and every route definition is now asserted to resolve to a handler

Plan, verification notes and the deviations from it are in
docs/plans/paid-model-price-tracking.md.
2026-10-04 08:53:24 +08:00
Will Miao 5f4054265d fix(update): treat a timed paidAccess gate with no recorded end as active
CivitAI only returns a non-null paidAccess for an *active* gate: a lapsed gate
stays in the database as a tombstone and is filtered out server-side, so
{"permanent": false, "endsAt": null} — a timed gate whose window end has not
been recorded yet — is still enforced. Verified live: on model 1802980 that
version reports canDownload: false while its lapsed siblings report true.

Both the update service and the download gate dropped that shape, so such
versions read as free and "Hide Early Access Updates" missed them — the class
of bug reported in #1060.

The interpretation now lives in py/utils/paid_access.py and is shared, so the
badge, the update filter and the download warning cannot disagree.
2026-10-04 08:53:12 +08:00
97 changed files with 12086 additions and 725 deletions
+50
View File
@@ -0,0 +1,50 @@
# .comfyignore — keep the Comfy Registry archive to runtime-only files.
#
# comfy-cli builds node.zip as: git-tracked files − .comfyignore matches
# + [tool.comfy].includes (we declare none). Patterns use .gitignore
# (gitwildmatch) syntax, evaluated against paths relative to the repo root.
# https://docs.comfy.org/registry/publishing
#
# Why this file exists: the registry security scan flags a version on ANY
# finding, even severity "info", so every shipped file is scan surface.
# Dev-only files (tests, docs, tooling scripts, agent notes, Vue sources)
# accounted for ~44 of the 115 findings that flagged 1.2.2–1.2.4.
# Test suites
/tests/
# Developer documentation, plans and internal notes
/docs/
/.omo/
/.specs/
/AGENTS.md
/update_logs.md
# Agent skill definitions (repo tooling, not loaded by ComfyUI)
/.agents/
# CI and repository automation
/.github/
# Repository tooling scripts — maintenance only, never imported at runtime.
# scripts/api.js + scripts/app.js are ComfyUI stubs consumed by the vitest suite.
/scripts/
# Vue widget sources and build inputs. The prebuilt bundle that actually ships
# lives in web/comfyui/vue-widgets/. Dropping the sources also stops the startup
# auto-builder from running its mtime check (and an npm install) on end-user
# machines, since py/vue_widget_builder.py skips the check when src/ is absent.
/vue-widgets/
# Root npm tooling — frontend test runner only
/package.json
/package-lock.json
/vitest.config.js
/pytest.ini
/requirements-dev.txt
# Sourcemaps are build artifacts, never loaded at runtime
web/comfyui/vue-widgets/*.js.map
# Keep this file itself out of the archive
/.comfyignore
+30 -47
View File
@@ -24,7 +24,6 @@ try: # pragma: no cover - import fallback for pytest collection
except (
ImportError
): # pragma: no cover - allows running under pytest without package install
import importlib
import pathlib
import sys
@@ -32,47 +31,33 @@ except (
if str(package_root) not in sys.path:
sys.path.append(str(package_root))
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
TextLM = importlib.import_module("py.nodes.text").TextLM
LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM
LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM
CheckpointLoaderLM = importlib.import_module(
"py.nodes.checkpoint_loader"
).CheckpointLoaderLM
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle"
).TriggerWordToggleLM
LoraStackerLM = importlib.import_module("py.nodes.lora_stacker").LoraStackerLM
LoraStackCombinerLM = importlib.import_module(
"py.nodes.lora_stack_combiner"
).LoraStackCombinerLM
SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
DebugMetadataLM = importlib.import_module("py.nodes.debug_metadata").DebugMetadataLM
WanVideoLoraSelectLM = importlib.import_module(
"py.nodes.wanvideo_lora_select"
).WanVideoLoraSelectLM
WanVideoLoraTextSelectLM = importlib.import_module(
"py.nodes.wanvideo_lora_select_from_text"
).WanVideoLoraTextSelectLM
LoraPoolLM = importlib.import_module("py.nodes.lora_pool").LoraPoolLM
LoraRandomizerLM = importlib.import_module(
"py.nodes.lora_randomizer"
).LoraRandomizerLM
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM
LoraInfoLM = importlib.import_module("py.nodes.lora_info").LoraInfoLM
LoraSyntaxToPath = importlib.import_module(
"py.nodes.lora_syntax_to_path"
).LoraSyntaxToPath
CreateHookLoraLM = importlib.import_module(
"py.nodes.create_hook_lora"
).CreateHookLoraLM
MetadataOverwriteLM = importlib.import_module(
"py.nodes.metadata_overwrite"
).MetadataOverwriteLM
LoadImageMetadataLM = importlib.import_module("py.nodes.load_image_metadata").LoadImageMetadataLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init
# pytest collects this file as a top-level module: its Package collector walks
# up from tests/ while __init__.py exists, so the repo root becomes a package
# node and this file is imported without one. Relative imports cannot resolve
# there, so pull the same objects through the top-level "py" package that the
# sys.path entry above makes importable.
from py.lora_manager import LoraManager
from py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from py.nodes.checkpoint_loader import CheckpointLoaderLM
from py.nodes.unet_loader import UNETLoaderLM
from py.nodes.trigger_word_toggle import TriggerWordToggleLM
from py.nodes.prompt import PromptLM
from py.nodes.text import TextLM
from py.nodes.lora_stacker import LoraStackerLM
from py.nodes.lora_stack_combiner import LoraStackCombinerLM
from py.nodes.save_image import SaveImageLM
from py.nodes.debug_metadata import DebugMetadataLM
from py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
from py.nodes.wanvideo_lora_select_from_text import WanVideoLoraTextSelectLM
from py.nodes.lora_pool import LoraPoolLM
from py.nodes.lora_randomizer import LoraRandomizerLM
from py.nodes.lora_cycler import LoraCyclerLM
from py.nodes.lora_info import LoraInfoLM
from py.nodes.lora_syntax_to_path import LoraSyntaxToPath
from py.nodes.create_hook_lora import CreateHookLoraLM
from py.nodes.load_image_metadata import LoadImageMetadataLM
from py.nodes.metadata_overwrite import MetadataOverwriteLM
from py.metadata_collector import init as init_metadata_collector
NODE_CLASS_MAPPINGS = {
PromptLM.NAME: PromptLM,
@@ -107,12 +92,10 @@ try:
# Auto-build in development, warn only if fails
check_and_build_vue_widgets(auto_build=True, warn_only=True)
except ImportError:
# Fallback for pytest
import importlib
# Fallback for pytest (see the note in the import block above): go through the
# top-level "py" package, which that block has already put on sys.path.
from py.vue_widget_builder import check_and_build_vue_widgets
check_and_build_vue_widgets = importlib.import_module(
"py.vue_widget_builder"
).check_and_build_vue_widgets
check_and_build_vue_widgets(auto_build=True, warn_only=True)
except Exception as e:
import logging
@@ -1,5 +1,10 @@
# Multi-Library Management for Standalone Mode
> **Deprecated:** Multi-library support is deprecated and will be removed in an
> upcoming release. Multiple folder roots within a single library
> (`folder_paths` / `extra_folder_paths`) remain fully supported. This document
> is kept for historical reference only.
## Requirements Summary
- **Independent libraries**: In standalone mode, users can maintain multiple libraries, where each library represents a distinct set of model folders (LoRAs, checkpoints, embeddings, etc.). Only one library is active at any given time, but users need a fast way to switch between them.
- **Library-specific settings**: The fields that vary per library are `folder_paths`, `default_lora_root`, `default_checkpoint_root`, and `default_embedding_root` inside `settings.json`.
+125
View File
@@ -139,6 +139,61 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> `.showcaseLayoutOptions.gallery` / `.vertical`, `modals.model.showcase.layoutGallery` /
> `.layoutList`). All 9 locales are translated (terminology in §2, "Showcase layout
> feature"), so the "no remaining placeholders" claim holds again.
> **Status (2026-10, Buzz download prices):** the paid/early-access obtainability feature added
> 13 keys — the two `globalContextMenu.checkModelUpdates.gateEvents.*` counts, five
> `modals.model.versions.badges.*` (sale, Blue Buzz, "free now" and its tooltip, and the early
> access end-date tooltip) and the six `settings.priceTracking.*` strings — plus the section
> header. All 13 are now translated in all 9 locales. **Buzz** and **Blue Buzz** stay as-is
> everywhere (CivitAI currency names, R3). Two source fixes came with the pass: the unused
> `settings.priceTracking.label` key was removed (no template renders it; the toggle uses
> `enabled`/`enabledHelp`) and `settings.sections.priceTracking` was reworded to
> "Buzz Download Prices" so the header matches what the feature does (prices are displayed;
> nothing is tracked for alerts). Register follows each file's existing norm: 你 (zh-CN),
> 您 (zh-TW), Sie (de), tú (es), вы (ru). No remaining `[TODO: Translate]` placeholders.
> **Status (2026-10, scoped scan):** the Refresh dropdown gained a per-root scope section
> (`loras.controls.refresh.scopeSection` / `.rootOffline` / `.rootModels`) and the scan result
> toasts (`toast.api.refreshCompleteScoped`, `.refreshKeptUnreachable`, `.scanRootUnreachable`),
> 6 keys in total, translated in all 9 locales in the same pass (renderings in §2, "Scoped scan
> and root availability"). The sidebar's folder-level follow-up added 2 more
> (`sidebar.scanFolder`, `sidebar.scanFolderResult.missing`), translated in a second pass, so the
> "no remaining placeholders" claim holds again.
> **Status (2026-10, recipes folder protection):** the recipes storage directory (the first
> lora root's `recipes/` by default) is now excluded from the folder sidebar and refused by the
> folder delete/rename endpoints, adding 3 keys (`sidebar.deleteFolderModal.protectedTitle` /
> `.protectedMessage`, `sidebar.folderRoot.protectedStatus`). All 9 locales are translated
> (terminology in §2, "Folder sidebar feature" — the `recipe library` row), so the "no
> remaining placeholders" claim holds again.
> **Status (2026-10, reconcile walk progress):** a regular Refresh now reports the reconcile
> walk per model root (which roots are being checked, how many model files have been seen, and
> an ETA) and splits the progress bar into walk (0-50 %) and new-file (50-99 %) phases, which
> added the single `common.scanProgress.walkFiles` fragment. All 9 locales are translated
> (renderings in §2, "Scan progress (walk phase)"), so the "no remaining placeholders" claim
> holds again. The same pass scoped the client-side ETA to the current stage in
> `static/js/api/baseModelApi.js`; no other locale string changed.
> **Status (2026-10, multi-root folder resolution):** the folder sidebar used to turn a tree
> node into a path by prefixing the *default* model root, so a folder living under another root
> failed to delete ("Folder no longer exists") and a same-named folder in another root could be
> the one that got hit. Folder operations now resolve a node through the backend
> (`GET /api/lm/{prefix}/resolve-folder`) and act on the directories that really exist. When
> several roots hold the folder, the delete modal lists **one checkbox row per copy** — each row
> carries its own guard verdict, every deletable copy is ticked by default, and a copy that
> still holds models (or is a symbolic link) is unticked, disabled and explained instead of
> silently dropped. Deleting a subset leaves the node in the sidebar, because the folder list is
> a union over the model roots; `deleteFolderModal.keptNote` warns about that before the click.
> The scanner bookkeeping is root-aware for the same reason: deleting one copy no longer hides
> the node until the next scan, and no longer purges the model cards of its same-named twin in
> another root (rename keeps the twin's records untouched too). That added 26 keys —
> `sidebar.deleteFolderModal.{missingTitle,missingMessage,symlinkTitle,symlinkMessage,confirmMulti,deleting,keptNote}`,
> `sidebar.deleteFolderResult.{missing,symlink,successMulti,partial}`,
> `sidebar.renameFolderResult.{missing,symlink}`, the 12 `sidebar.folderRoot.*`
> chooser/status strings and `sidebar.folderResult.unresolved` — all translated in all 9
> locales (terminology below). The `en.json` wording was normalized to the established **model
> root** noun while translating (`library root` → `model root`, R5); the count-bearing
> `successMulti` uses the singular `success` key when only one copy was removed.
---
@@ -480,6 +535,31 @@ placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessa
`notEmptyMessageExcluded` and `notEmptyWithCount`; `notEmptyMessageExcluded` is the only key
carrying `{excluded}`.
#### Multi-root folder operations (resolution, copies, symlinks)
The sidebar's folder tree is a union over the model roots, so a relative folder can stand for
several directories at once. Copy is called **copy** (one directory per root), never "version"
or "instance"; a directory the backend refuses to touch is called out as a **symbolic link**,
and a per-row verdict reads **no models** when the copy is deletable:
| Term | Rendering |
|---|---|
| copy (one directory per root holding the same relative folder) | zh-CN 副本 · zh-TW 副本 · ja コピー · ko 복사본 · fr copie · de Kopie · es copia · ru копия · he עותק |
| symbolic link | zh-CN 符号链接 · zh-TW 符號連結 · ja シンボリックリンク · ko 심볼릭 링크 · fr lien symbolique · de symbolischer Link · es enlace simbólico · ru символическая ссылка · he קישור סמלי |
| recipe library (the protected `recipes/` storage dir) | zh-CN 配方库 · zh-TW 配方庫 · ja レシピライブラリ · ko 레시피 라이브러리 · fr bibliothèque de Recipes · de Rezept-Bibliothek · es biblioteca de recetas · ru библиотека рецептов · he ספריית המתכונים — the Recipe noun follows §1-R4 per locale; `protectedStatus` is a row verdict (fragment, no capitalization/period, keeps the locale's `—`/`——` dash style of the sibling `symlinkStatus`) |
| unchecked (row / note wording) | zh-CN 未勾选 · zh-TW 未勾選 · ja チェックを外した · ko 선택하지 않은 · fr non cochée · de nicht angehakt · es no marcada · ru неотмеченная · he שלא סומן |
| "no models" (row verdict) | zh-CN 无模型 · zh-TW 無模型 · ja モデルなし · ko 모델 없음 · fr aucun modèle · de keine Modelle · es sin modelos · ru моделей нет · he אין מודלים |
| model root — plural ("more than one model root", "from {count} model roots") | zh-CN 模型根目录(多个模型根目录)· zh-TW 模型根目錄(多個模型根目錄)· ja モデルルート(複数のモデルルート)· ko 모델 루트(여러 모델 루트)· fr racine de modèle (plusieurs racines de modèle) · de Modell-Stammverzeichnis (mehrere Modell-Stammverzeichnisse) · es raíz de modelo (más de una raíz de modelo) · ru корневая папка моделей (несколько корневых папок моделей) · he שורש מודלים (יותר משורש מודלים אחד) |
`{count}` / `{total}` / `{failed}` / `{excluded}` / `{name}` are verbatim §1-R2 placeholders in
`deleteFolderModal.confirmMulti`, `deleteFolderResult.successMulti` / `.partial` and
`folderRoot.notEmptyStatus` / `.notEmptyExcludedStatus`. The row verdicts are **fragments, not
sentences**: they sit under a path inside the chooser box, so no locale capitalizes them or adds
a period (`de` keeps its lowercase start, `fr` too). `folderRoot.rootsHint` ends with each
locale's colon (`:` for CJK, ASCII elsewhere). `partial` reuses the locale's "N of M" shape and
keeps the `—` before the failed count; `successMulti` keeps the locale's plain
`deleteFolderResult.success` verb and appends the root count.
### Settings Organization tab
The settings modal's fourth nav tab groups everything about how files are arranged on
@@ -658,6 +738,51 @@ that form fits a tooltip (ru shortens both toggle labels to bare «Галере
| gallery / vertical list (option labels) | zh-CN 画廊 / 纵向列表 · zh-TW 圖庫 / 垂直清單 · ja ギャラリー / 縦並びリスト · ko 갤러리 / 세로 목록 · fr Galerie / Liste verticale · de Galerie / Vertikale Liste · es Galería / Lista vertical · ru Галерея / Вертикальный список · he גלריה / רשימה אנכית |
| gallery view / list view (toggle tooltips) | zh-CN 画廊视图 / 列表视图 · zh-TW 圖庫檢視 / 清單檢視 · ja ギャラリー表示 / リスト表示 · ko 갤러리 보기 / 목록 보기 · fr Vue galerie / Vue liste · de Galerieansicht / Listenansicht · es Vista de galería / Vista de lista · ru Галерея / Список · he תצוגת גלריה / תצוגת רשימה |
### Scoped scan and root availability
A refresh can be restricted to one model root (the Refresh ▾ menu) or one folder (the sidebar
context menu). The menu labels a root and reports whether it can be read at all; the result
toasts report what the scan changed and what it deliberately left alone because a path could not
be read (an unreachable drive is no longer treated as deleted).
| Term | Rendering |
|---|---|
| `scopeSection` ("Scan one folder") | zh-CN 只扫描一个文件夹 · zh-TW 只掃描一個資料夾 · ja フォルダを 1 つだけスキャン · ko 폴더 하나만 스캔 · fr Analyser un seul dossier · de Nur einen Ordner scannen · es Escanear solo una carpeta · ru Сканировать только одну папку · he סרוק תיקייה אחת בלבד |
| `rootOffline` ("Offline") | zh-CN 离线 · zh-TW 離線 · ja オフライン · ko 오프라인 · fr Hors ligne · de Offline · es Sin conexión · ru Недоступен · he לא זמין |
| `rootModels` ("{count} models") | zh-CN {count} 个模型 · zh-TW {count} 個模型 · ja {count} 個のモデル · ko 모델 {count}개 · fr {count} modèles · de {count} Modelle · es {count} modelos · ru {count} модел(ей) · he {count} מודלים |
| `refreshCompleteScoped` ("Scanned {scope}: {added} new, {removed} removed") | zh-CN 已扫描 {scope}:新增 {added},移除 {removed} · zh-TW 已掃描 {scope}:新增 {added},移除 {removed} · ja {scope} をスキャンしました:新規 {added} 件、削除 {removed} 件 · ko {scope} 스캔 완료: 새 모델 {added}개, 제거 {removed}개 · fr {scope} analysé : {added} nouveau(x), {removed} supprimé(s) · de {scope} gescannt: {added} neu, {removed} entfernt · es {scope} escaneado: {added} nuevo(s), {removed} eliminado(s) · ru {scope}: просканировано — новых {added}, удалено {removed} · he {scope} נסרק: {added} חדשים, {removed} הוסרו |
| `refreshKeptUnreachable` ("{count} models kept: {paths} not reachable") | zh-CN 已保留 {count} 个模型:{paths} 当前不可访问 · zh-TW 已保留 {count} 個模型:{paths} 目前無法存取 · ja {count} 個のモデルを保持しました:{paths} にアクセスできません · ko 모델 {count}개 유지됨: {paths}에 접근할 수 없음 · fr {count} modèles conservés : {paths} inaccessible(s) · de {count} Modelle beibehalten: {paths} nicht erreichbar · es {count} modelos conservados: {paths} no accesible(s) · ru Сохранено моделей: {count} — {paths} недоступны · he נשמרו {count} מודלים: {paths} אינם זמינים |
| `scanRootUnreachable` ("{scope} is not reachable right now. Nothing was changed.") | zh-CN {scope} 当前不可访问,未做任何改动。 · zh-TW {scope} 目前無法存取,未做任何變更。 · ja {scope} に現在アクセスできません。変更は行われていません。 · ko {scope}에 현재 접근할 수 없습니다. 변경된 내용은 없습니다. · fr {scope} est actuellement inaccessible. Aucune modification n’a été apportée. · de {scope} ist derzeit nicht erreichbar. Es wurde nichts geändert. · es {scope} no es accesible ahora mismo. No se ha cambiado nada. · ru {scope} сейчас недоступен. Изменений не внесено. · he {scope} אינו זמין כעת. לא בוצעו שינויים. |
| `sidebar.scanFolder` ("Scan this folder") | zh-CN 扫描此文件夹 · zh-TW 掃描此資料夾 · ja このフォルダをスキャン · ko 이 폴더 스캔 · fr Analyser ce dossier · de Diesen Ordner scannen · es Escanear esta carpeta · ru Сканировать эту папку · he סרוק תיקייה זו |
| `sidebar.scanFolderResult.missing` | identical to `sidebar.renameFolderResult.missing` / `deleteFolderResult.missing` in every locale (the folder vanished between the menu opening and the click) — reuse that rendering rather than writing a third variant |
`{scope}` is a root label (`G: loras`) or a folder path, `{paths}` is a comma-joined list capped
at three entries, and both counts arrive pre-formatted. "Offline" describes a configured root
whose directory cannot be read right now (drive switched off, unmounted share) — it is a state of
the *root*, never a property of the models inside it, which stay in the library.
### Scan progress (walk phase)
The manual Refresh dialog renders its status line from `common.scanProgress.*`. During the
reconcile walk the backend does not know the real file count yet (counting *is* the walk), so
the client shows the roots being walked plus a running count instead of a `processed/total`
ratio: `Checking for changes... G:, Y: (12,345 files) | ~3 min remaining`. The bar covers
0-50 % for the walk and 50-99 % for the new-file pass.
| Term | Rendering |
|---|---|
| `walkFiles` ("{count} files") | zh-CN {count} 个文件 · zh-TW {count} 個檔案 · ja {count} 件のファイル · ko 파일 {count}개 · fr {count} fichiers · de {count} Dateien · es {count} archivos · ru {count} файл(ов) · he {count} קבצים |
`walkFiles` is a **fragment, not a sentence**: the root labels, the `(` `)`, the ` | ` before
the ETA and the ETA text itself all come from `static/js/api/baseModelApi.js`, so no locale
carries punctuation here (the ASCII parentheses match the sibling `stages.process_models`
count, `(5/10)`). `{count}` is substituted with an already-formatted number
(`toLocaleString()`), so no locale adds its own digit grouping. "files" means the **model
files the walk looked at**, not every file on disk — the noun mirrors each locale's
`stages.count_models` rendering, and `ru` uses the `файл(ов)` form because the count ticks
live and can be any number (`notEmptyMessageCount` precedent).
---
## 3. Cross-cutting confusion hot-spots (must-fix list)
+5
View File
@@ -1,5 +1,10 @@
# Library Switching and Preview Routes
> **Deprecated:** Multi-library support (and therefore library switching) is
> deprecated and will be removed in an upcoming release. The dynamic preview
> route described here will remain — it also serves single-library
> installations.
Library switching no longer requires restarting the backend. The preview
thumbnails shown in the UI are now served through a dynamic endpoint that
resolves files against the folders registered for the active library at request
+384
View File
@@ -0,0 +1,384 @@
# Plan: Scoped Scan (one root / one folder at a time)
**Issue:** [#1108](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1108) — scan a single
folder/root instead of the whole library.
**Status:** **P1, P2 and Wave 6 implemented** (2026-10-07). P1 shipped in `470d85cc` (translations
in `bd184559`), P2 in `12930ce7` (translations in `0941f992`). Supersedes the earlier draft's
per-root *status panel* and persisted *unreachable-subtree* state (both dropped, see
"Must NOT have").
Implementation notes / deviations from the draft below:
* The scope dataclass is public (`ReconcileScope`) because it crosses the scanner → service →
route boundary; the draft called it `_ReconcileScope`.
* `_reconcile_cache()` now returns a summary dict, exposed as
`ModelScanner.last_reconcile_summary` and returned by `BaseModelService.scan_models()`, so the
scan HTTP response carries the counts (the WS `completed` payload carries the same fields).
* Root labels are computed over the **whole configured set** and passed into the walk tracker, so
the progress line and the menu never disagree.
* Discovery: `Config._dedupe_existing_paths()` drops roots that do not exist **at config load**, so
a drive that is already off when LM starts is not in `get_model_roots()` at all. Two consequences:
it cannot appear in the refresh menu (nothing to select), and its cached entries are kept because
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
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
empty-folder list is unioned instead of replaced, so an empty folder deleted on disk can linger in
the sidebar until the next full refresh. Models are unaffected.
## TL;DR (For humans)
**What you'll get:** the Refresh ▾ menu can scan **one model root** (one drive) and leave every
other root untouched. Refreshing while a drive is switched off no longer deletes that drive's
models from the cache/DB — they are kept and reported. Browsing the grid while a drive is off no
longer silently strips `preview_url` from that drive's models.
**Why this approach:** a scan scope is a **path scope**, not a device scope, so it also works for
layouts where one root contains symlinks to other drives. The safety net collapses into one rule —
*anything this walk could not read is left untouched* — and that rule is nearly free: the root
existence check already exists, `os.walk(onerror=...)` only fires on failure, and the cached-entry
prefix attribution is already computed for the walk-progress weights.
**What it will NOT do:** no active probing of nested symlinks; no persisted "unreachable subtree"
table; no per-card offline badges (separate change); no root enable/disable switch; the main
Refresh button keeps meaning "scan everything"; no configuration-convention/documentation push to
re-layout libraries that use cross-drive symlinks (dropped by request).
**Effort:** Medium (~1.5–2 days for P1 including tests)
**Risk:** Medium-low — the risk sits in the folder-tree (`all_folders`) merge under a scoped scan
and in the exact pruning predicate; both get explicit tests.
**Decisions to sanity-check:**
1. A full Refresh now treats a *configured but unreachable* root as "keep + report" instead of
"everything under it was deleted". This is a behaviour change (release-note it).
2. Keep the near-zero-cost **first-level symlink** reachability check (reuses
`config._path_mappings`); nested symlinks stay uncovered.
3. Root labels become set-aware (`G: loras` / `usb/loras`, auto-deduped by parent segments).
This also changes the walk-progress line.
## Scope
### Must have (P1)
- **Scope-aware reconcile**: `_reconcile_cache(scope=...)` where scope is
`{roots: [path] | None, folder: rel | None}`. Files inside the scope reconcile normally
(add / repair / remove); everything outside is neither re-read nor removed.
- **Path-level pruning guard**: cached entries under a path this walk could not read are excluded
from `missing` and reported. Three sources:
- configured root that fails the reachability check (already filtered at
`model_scanner.py:1468`),
- directories `os.walk` failed to enter (`onerror` collector; covers Windows junctions to an
offline drive, permission errors, I/O errors),
- first-level symlink mappings whose target is not a directory (`config._path_mappings`).
- **Folder tree correctness under scope**: `all_folders` becomes
`(old outside scope) ∪ (old under unreachable paths) ∪ (discovered)`, so a scoped scan cannot
collapse the sidebar tree and an offline root keeps its folders.
- **Result payload** for the toast: `added`, `removed`, `repaired`, `scanned_roots` (labels),
`skipped_roots` (`{path, label, kept}`), `unavailable_paths` (`{path, kept}`, capped),
`kept_unreachable`.
- **API**: `GET /api/lm/{prefix}/scan?full_rebuild=false&roots=<path>` (repeatable);
`GET /api/lm/{prefix}/roots` gains `root_details: [{path, label, reachable, models}]` while
keeping `roots: [str]` unchanged (other callers depend on it).
- **Frontend**: Refresh ▾ menu lists the current page's roots (label + model count, offline rows
greyed out and non-clickable), main button unchanged, root items disabled while a scan runs;
scoped completion toast reports counts and any kept/skipped paths.
- **Preview fix**: a preview 404 must not clear `preview_url` when the file's parent directory is
itself unreachable (`preview_handlers.py:57` + `_cleanup_stale_preview_url`).
- **Set-aware root labels** (`_root_display_labels(roots)`), shared by the progress broadcasts and
`/roots`: last path segment, Windows drive prefix (`G: loras`), deduped by prepending real parent
segments (`usb/loras`, `a/models/loras`), `(2)` fallback by sorted path, 40-char cap, full path in
the tooltip.
- i18n keys in `locales/en.json` + `python scripts/sync_translation_keys.py`.
- pytest + vitest coverage for every item above.
### Must have (P2, implemented)
- `folder=<rel>` parameter: walk `<root>/<rel>` for every reachable root that contains it (the
sidebar's unified tree has no root identity, so "this folder" means "this relative path in all
roots"), prune only inside that prefix. Validation reuses `normalize_relative_folder()` (extracted
from `ModelMoveService` so the folder operations and the scan endpoint reject the same input:
absolute paths, drive letters, `..` climbing). The summary carries `scope_label` = the folder, so
the toast names the folder the user clicked rather than the roots it happens to live under.
- Sidebar folder context menu entry "Scan this folder" (`templates/components/context_menu.html`
`#sidebarFolderContextMenu`, above `check-folder-updates`; update
`tests/frontend/regression/sidebarFolderContextMenu.test.js` expectations). The entry is gated
like the other folder operations and resolves the folder through the existing
`_resolveFolderCandidates()` before scanning, so a folder no root holds any more explains itself
instead of scanning nothing. It shares `check-folder-updates`'s divider (both are refresh-ish).
### Must NOT have (guardrails)
- NO per-entry symlink probing during the walk — `DirEntry.is_symlink()` costs a syscall per entry
on Windows, the exact cost the reconcile optimisation removed. Nested symlinks stay uncovered.
- NO persisted `unavailable_paths` table, no DB schema change, no new cache state.
- NO configuration-convention guidance for cross-drive symlinks (dropped by request).
- NO per-card/modal offline badges in this plan (separate change; needs the L1 prefix list which
P1 produces, so it can build on this later).
- NO root enable/disable setting, NO status panel in Settings.
- NO change to `full_rebuild=true` (it still walks everything and replaces the cache);
`roots` + `full_rebuild=true` is rejected with 400.
- NO new dependency, NO change to the extension-facing endpoints.
- NO `os.path.realpath` for scope/prune routing — business paths only (AGENTS.md rule).
## Todos
### Wave 1 — backend semantics and safety net
1. **Scope plumbing in the scanner.**
What to do: add `_ReconcileScope` (dataclass: `roots: Optional[List[str]]`, `folder: Optional[str]`)
and `_reconcile_cache(scope=None)`. Resolve the effective root list once: configured roots ∩
scope.roots, filtered by reachability; missing ones become `skipped_roots` (never pruned).
`_walk_roots_for_reconcile` / `_walk_root_group_sync` / `_walk_root_for_reconcile` take the scope
so the walk can start at `<root>/<folder>` while still computing `folder`/`file_path` relative to
the **root** (`_process_model_file(path, root_path)` must keep receiving the root).
References: `py/services/model_scanner.py:1453` (`_reconcile_cache`), `:1468` (root filter),
`:1832` (`_walk_root_group_sync`), `:365` (`_walk_root_for_reconcile`), `:134`
(`_count_cached_entries_per_root`), `:126` (`_normalized_root_prefix`).
Done when: a scoped reconcile over 2 roots leaves the other root's `raw_data`, hash index and
folder list byte-identical.
2. **Pruning predicate + unreachable collection.**
What to do: `missing = {p for p in cached_paths - found_paths if in_scope(p) and not
under_unreachable(p)}`; collect unreachable prefixes from (a) skipped roots, (b) an `onerror`
callback on `os.walk` (record `oserror.filename` as a business path), (c) `config._path_mappings`
entries whose target fails `os.path.isdir` (mapping is keyed target→link; protect the **link**
prefix). Count the kept entries per prefix for reporting.
References: `py/services/model_scanner.py:1679` (`missing_files = cached_paths - found_paths`),
`:398` (`os.walk(..., followlinks=True)`), `py/config.py:167` (`_path_mappings`),
`:801` (`add_path_mapping`, target→link), `:750-775` (first-level-only symlink scan).
Done when: with a root removed from disk (or a directory replaced by an unreadable junction),
`raw_data` keeps those entries, `removed` is 0, and the counts land in the result payload.
3. **Folder-tree merge under scope.**
What to do: replace the unconditional `sorted_discovered` assignment with the union rule from
"Must have"; keep the `folders_changed` comparison and the persist path unchanged. Unscoped
scans with nothing unreachable must produce exactly today's list.
References: `py/services/model_scanner.py` (walk loop records `discovered_folders`; `model_scanner.py:1738`
(`sorted_discovered`) and `:1739` (`folders_changed`) after the dedup pass).
Done when: a scoped scan keeps folders outside the scope, an offline root keeps its subtree, and
a full scan still drops folders deleted on disk.
4. **Result payload + progress/cancel/complete messages.**
What to do: extend the `completed` broadcast with `scanned_roots`, `skipped_roots`,
`unavailable_paths`, `kept_unreachable`, `repaired`; the walk tracker already receives only the
scoped roots, so the progress line shows that root's label with `roots_total=1`.
References: `py/services/model_scanner.py` `_broadcast_scan_progress` (`:576`),
`_ReconcileWalkTracker` (`:266`), the completed broadcast at the end of `_reconcile_cache`.
Done when: a scoped scan emits `stage=reconcile_scan` with the single scoped label and a
`completed` payload carrying the new fields.
5. **API surface.**
What to do: `scan_models` accepts repeated `roots` query params (400 on unknown root, 400 when
combined with `full_rebuild=true`); `get_model_roots` adds `root_details` (label, reachable,
model count via the shared prefix attribution) without touching `roots`.
References: `py/routes/handlers/model_handlers.py:1139` (`scan_models`), `:1165`
(`get_model_roots`), `py/routes/model_route_registrar.py:61-62`, `static/js/api/apiConfig.js:114`.
Done when: `tests/routes/test_lora_routes.py`-style coverage passes for the new params and the
backward-compatible `/roots` payload.
6. **Preview 404 must not prune when the parent is unreachable.**
What to do: in `serve_preview`, before `_cleanup_stale_preview_url`, check
`os.path.isdir(os.path.dirname(resolved))`; if the directory itself is missing/unreachable,
return 404 **without** clearing caches. (Centralized sidecar mode puts the preview in the mirror
tree, so the check must use the preview file's own parent.)
References: `py/routes/handlers/preview_handlers.py:57` (404 branch), `:74-110`
(`_cleanup_stale_preview_url` → `clear_preview_by_path` + `_persist_current_cache`),
`tests/routes/test_preview_routes.py`.
Done when: a test asserts the cache keeps `preview_url` when the parent dir is absent and still
clears it when the file was really deleted from a reachable directory.
### Wave 2 — labels
7. **`_root_display_labels(roots)` (set-aware, order-independent).**
What to do: replace the single-root `_root_display_label`; compute over the sorted root list;
prepend real parent segments until unique; `(2)`/`(3)` fallback; 40-char middle-ellipsis; return
`{path: label}`. Use it in `_ReconcileWalkTracker.__init__` (progress) and in the `/roots`
handler so both always agree.
References: `py/services/model_scanner.py:117` (`_root_display_label`, current single-root
version), `:266` (`_ReconcileWalkTracker`).
Done when: unit tests cover `usb/loras` + `ssd/loras`, `a/models/loras` + `b/models/loras`,
Windows `G:\x\loras` + `H:\y\loras` → `G: loras` / `H: loras`, and the identical-path fallback.
### Wave 3 — frontend
8. **Refresh ▾ scope section.**
What to do: add an empty container to `templates/components/controls.html` inside
`.dropdown-menu` (`#refreshScopeMenu`) + a section title; on dropdown open, fetch
`endpoints.roots`, render one row per root (`data-action="scan-root"`, `data-root="<path>"`,
label + model count, `disabled` + "Offline" for unreachable), hide the section on the recipes
page (recipes have no roots); wire clicks by **event delegation** on the menu (the existing
`full-rebuild` item is bound with a direct `querySelector`, dynamic rows cannot be);
invalidate the cached list after a scan finishes; add CSS for the section + `max-height`/
`overflow-y` so 4+ roots stay usable.
References: `templates/components/controls.html:67-76`, `static/js/components/controls/PageControls.js:131`
(`data-action="refresh"`), `:220-260` (`initDropdowns` + `full-rebuild` wiring),
`static/js/api/apiConfig.js:118` (`roots` endpoint), `static/js/api/baseModelApi.js:1318`
(`fetchModelRoots`).
Done when: the menu lists roots with counts, an offline root is visibly disabled, and clicking a
row starts a scoped refresh.
9. **Scoped request + toast.**
What to do: `refreshModels(fullRebuild, {roots})` appends repeated `roots` params; the
`completed` payload drives the toast (`added`/`removed`/`kept_unreachable`/`skipped_roots`);
offline rows toast `… is not reachable right now. Nothing was changed.` without a request.
References: `static/js/api/baseModelApi.js:514-602` (refreshModels, URL build at `:601`, toast
at `:618`), `static/js/components/controls/PageControls.js:485`.
Done when: the frontend test asserts the scoped URL and both toast variants.
10. **i18n.**
What to do: add `loras.controls.refresh.scopeSection`, `.rootOffline`, `.rootModels`,
`toast.api.refreshCompleteScoped`, `toast.api.refreshKeptUnreachable`,
`toast.api.scanRootUnreachable` to `locales/en.json`, run
`python scripts/sync_translation_keys.py`, then stop (placeholders are the expected state until
translations are requested).
Done when: `pytest tests/i18n` passes and every locale has the keys.
### Wave 4 — tests and verification
11. **Backend tests** (`tests/services/test_model_scanner.py`, `tests/routes/test_lora_routes.py`,
`tests/routes/test_preview_routes.py`):
`test_reconcile_scoped_scan_leaves_other_roots_untouched`,
`test_reconcile_scoped_scan_removes_deleted_files_in_scope`,
`test_reconcile_scoped_scan_preserves_folder_tree_outside_scope`,
`test_reconcile_keeps_entries_of_unreachable_root`,
`test_reconcile_keeps_entries_under_unreadable_dir`,
`test_reconcile_keeps_entries_under_offline_first_level_symlink`,
`test_root_display_labels_dedupe_by_parent_segments`,
`test_scan_models_accepts_roots_param`, `test_scan_rejects_unknown_root`,
`test_scan_rejects_roots_with_full_rebuild`, `test_roots_endpoint_reports_details`,
`test_preview_404_keeps_cache_when_parent_dir_missing`.
12. **Frontend tests**: extend `tests/frontend/api/baseModelApi.refresh.test.js` (scoped URL, both
toasts) and add `tests/frontend/components/controls/pageControls.scanRoots.test.js`
(rows rendered from `/roots`, offline row disabled, delegation wiring).
13. **Full suites + sandbox eyeball**: `pytest -q`, `npx vitest run`, `npm run test:vue`; then a
sandboxed standalone instance with two roots and a slowed walk
(`LM_WALK_DELAY_S`-style `sitecustomize` hook) to eyeball the scoped progress line, the offline
row and the toast — the user verifies by eye.
### Wave 5 — P2 (folder scope) — done
14. `folder=<rel>` in the scan endpoint + `ReconcileScope.folder`, the sidebar menu entry, the
context-menu regression test update, and the "folder in several roots" semantics (scan every
reachable root that has the relative path; report per root).
* `normalize_relative_folder()` extracted to module scope in `model_file_service.py` (the
private static method now delegates) and reused by the scan handler.
* `scanFolder()` in `SidebarManager` reuses `_resolveFolderCandidatesSafe()` and delegates to the
host page controls, which pass `{ folder }` through `registerAPI`'s argument-forwarding
`refreshModels`.
* Verified live: `folder=pack000` on the three-root sandbox walks drive-G and drive-Y, reports
`scope_label=pack000`, keeps drive-Z's 6 entries under that folder (`kept_unreachable=6`) and
leaves all 420 models cached.
## Wave 6 — configured vs currently available roots (approved follow-up)
The P1/P2 work exposed an asymmetry: `Config._dedupe_existing_paths()` drops roots whose directory
does not exist **at the moment the root list is built** (startup, or applying a library snapshot),
so a drive that is switched off while LM starts is not "an offline root" — it is not a root at all.
Consequences, all verified on the sandbox:
* `/roots` does not list it, so the Refresh ▾ menu shows no row for it in either state;
* a full refresh reports nothing (`kept_unreachable: 0`, no toast) even though its cached entries
are kept — they survive only because they fall outside every configured root prefix;
* plugging the drive back in does not bring the row back: the in-memory list is not re-validated;
* **plugin mode only:** `Config.save_folder_paths_to_settings()` (called from `Config.__init__`)
persists `target_folder_paths["loras"] = list(self.loras_roots)` through
`upsert_library(folder_paths=...)`, which **replaces** the library's paths. Starting ComfyUI with
a drive switched off therefore *erases that path from `settings.json`* — a configuration loss,
not just a display gap. Extra paths are unaffected (that call reuses the stored
`extra_folder_paths`), and the same pattern applies to checkpoints/unet/embeddings/other-model
primary paths.
### Must have
1. **Never erase an unavailable root from the library config.** `Config` records the *configured*
(existence-unfiltered) primary paths per model type while building the live lists, and
`save_folder_paths_to_settings()` persists those instead of the filtered ones.
`_resolve_valid_default_root()` receives the configured paths in `allowed_paths` too, so a
`default_*_root` that sits on a switched-off drive is not "repaired" away.
2. **Report configured-but-unavailable roots.** `Config.configured_roots_for(model_type)` feeds
`ModelScanner.describe_model_roots()`, which appends them with `available: false`, a live
`reachable` and their cached entry count — so the Refresh ▾ menu has a row in **both** states
(greyed while the directory is missing, normal and clickable once it is back). The reconcile
summary reports them as `skipped_roots` / `unavailable_paths` with reason `root_unavailable` and
counts their cached entries in `kept_unreachable`, which is what makes the "N models kept" toast
appear in the startup-offline case.
3. **Admit them again, append-only.** `Config.admit_configured_roots()` re-runs the per-type
prepare helpers against the configured paths and **appends** what exists now
(plus the checkpoint/unet/other side maps) to the live lists, then refreshes the preview
allowlist. Called from `/roots` and `/scan`, so a drive plugged in mid-session can be scanned
without restarting. Appending (never re-sorting) keeps `loras_roots[0]` — which derives the
recipes directory and the usage-stats file location — stable for the whole session.
### Must NOT have
* NO removal of a root from the live lists mid-session (that is what would let a later settings
save persist a reduced configuration, and it would move `*_roots[0]`).
* NO change to the scan/prune scope: a root that is still unavailable stays out of scope, so its
entries remain "kept because out of scope" exactly as today.
* NO persisted unavailable-state, no DB schema change, no per-entry filesystem probe.
### Where "configured" comes from
`Config` records the existence-unfiltered paths while building the live lists, but the *source*
differs per mode and that matters:
* **Plugin mode:** `folder_paths.get_folder_paths()` is ComfyUI's own list, unfiltered — a path the
user removed there must be forgotten, one that is merely missing must be kept. The host list wins.
* **Standalone mode:** `standalone.MockFolderPaths.get_folder_paths()` already filters
`os.path.exists` out of `settings.json`, so the host list cannot answer "what did the user
configure". The active **library snapshot** (`libraries[<active>].folder_paths`, falling back to
the top-level `folder_paths`) is the record, and it wins there.
(`_remember_library_configured_paths()` implements the split.)
### Verification
* Config: a configured path that does not exist is still written back by
`save_folder_paths_to_settings()`; a path the host no longer configures is still dropped.
* Promotion: create the directory after the lists were built → `admit_configured_roots()` adds it
**at the end**, keeps every existing root in place, and adds nothing twice.
* Scanner: `describe_model_roots()` reports the missing root (`available: false`, cached count);
a full refresh lists it in `skipped_roots`/`unavailable_paths` and keeps its entries; `/scan`
with that root works once the directory is back.
* Sandbox, drive-Z gone **before** startup: `/roots` reports `drive-Z` with
`reachable=false available=false models=60`; a full refresh returns
`skipped_roots=[drive-Z root_unavailable]`, `unavailable_paths=[{... kept: 60}]`,
`kept_unreachable=60` and leaves all 432 models cached. *(Both measured; before this wave the
menu had no row and the refresh reported nothing.)*
* Sandbox, drive renamed back **while the server runs**: `/roots` admits it
(`available=true reachable=true`) and `GET /scan?roots=<drive-Z>` walks it
(`scanned_roots=['drive-Z']`, 0 added / 0 removed) — no restart required. *(Measured.)*
## Known limitations (accepted)
- **Nested symlinks** (a symlink *below* a root pointing at another drive) are only covered when
`os.walk` fails to enter the target (Windows junctions, permission errors). A broken symlink that
`os.walk` classifies as a non-directory (typical on POSIX) is not detected: its entries are pruned
as today and come back on the next scan once the target is reachable (sidecars carry the sha256,
so no re-hash). Verify the Windows junction behaviour before writing release notes.
- Only **first-level** symlinks are known to `config` (by design, `config.py:750-775`), so the
symlink reachability check inherits that limit.
- A scoped scan of a folder does not re-read metadata for unchanged files (unchanged behaviour).
- Empty folders removed on disk can linger in the sidebar after a scoped scan (the folder list is
unioned rather than replaced whenever the scan did not verify every root); the next full refresh
drops them.
- `root_details[].reachable` is a live `os.path.exists()` per root, so a root that disappears
mid-session shows as offline; a root that was already gone at startup is still listed thanks
to Wave 6 (`available: false`, cached count), and `/roots` re-admits it once the directory
is back.
## Verification checklist
- [x] `pytest -q` green (3704 passed, 7 skipped), `npx vitest run` green (1495 passed),
`npm run test:vue` green (96 passed).
- [x] `python scripts/sync_translation_keys.py --dry-run` reports no pending changes.
- [x] Sandbox: 3 roots, one offline → scoped scan of one root walks only it; a full refresh keeps
the offline root's models (`kept_unreachable=60`) and the grid still shows all 420.
- [x] Sandbox: folder scope (`folder=pack000`) walks the two reachable roots, labels the scan by the
folder and keeps the offline root's 6 entries under it.
- [ ] Sandbox: preview of an offline root's model returns 404 and the DB keeps `preview_url`
(test-locked; not eyeballed in the sandbox because the demo models have no previews).
- [ ] Release note wording agreed for the behaviour change (decision 1).
- [ ] The 2 new sidebar keys (`sidebar.scanFolder`, `sidebar.scanFolderResult.missing`) are
`[TODO: Translate]` placeholders pending the feature owner's go-ahead.
+914
View File
@@ -0,0 +1,914 @@
# Plan: Buzz Price Tracking and Threshold Alerts for Paid / Early Access Versions
**Origin:** FR "Price tracker for models/loras" (Geekier, Discord) — track the buzz price of paid
model versions and flag when it drops below a threshold or becomes free.
**Related:** [#1060 — Some "Early Access" models are not identified correctly](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1060)
(closed) established `is_paid`, the Paid badge and `hide_paid_updates`; this FR is the next step
after gate *state* — gate *price*.
**Status:** v3 — **P0–P3 implemented** (§10 records what shipped and the deviations); **P5 (alerts
panel) planned** in §11, not implemented. The grid-level "price alert only" filter was dropped by
owner decision, so §11 defines the panel as the only new browsing surface.
Feasibility was verified against this repo and upstream CivitAI `main` (`6d29ed1368`), including live
probes against `civitai.com` / `civitai.red`.
**Scope:** every model type that goes through `ModelUpdateService` (lora / checkpoint / embedding /
other). Out of scope: purchasing, downloading gated content, and any change that depends on a future
CivitAI API addition.
---
## 1. Problem statement
Creators increasingly gate model versions behind buzz. LoRA Manager already knows **whether** a
version is gated (`is_paid`, `is_early_access`, `paid_access`) but not **how much it costs**, so it
cannot answer the two questions the FR asks:
1. "Tell me when this model's price drops below X buzz."
2. "Tell me when it becomes free."
Additionally, the current code only compares version ids when deciding `has_update`, so a version
that a user already tracks can go from paid to free (or gain a gate) without producing any signal at
all.
## 2. Verified current state
### 2.1 LoRA Manager (all references confirmed in this checkout)
| Fact | Reference |
| --- | --- |
| Gate fields persisted per version: `is_paid`, `is_early_access`, `paid_access` (raw DTO JSON), `early_access_ends_at` | `py/services/model_update_service.py:65-84` |
| Gate data comes from the bulk `GET /api/v1/models?ids=…` response (`availability`, `paidAccess`, `earlyAccessEndsAt`) | `py/services/civitai_client.py:329-380`, parsed at `py/services/model_update_service.py:1756-1793` |
| **No numeric price anywhere in `py/`** | `grep -rn "price" py/` → only license/`allowCommercialUse` hits |
| Update refresh is user-triggered, not scheduled; TTL 24 h | route `py/routes/handlers/model_handlers.py:2899`; `ModelUpdateService.__init__` default `ttl_seconds=24*60*60` at `py/services/model_update_service.py:338` |
| `has_update` compares version ids + gate filters only; no state-transition detection | `py/services/model_update_service.py:117-160` |
| `{permanent:false, endsAt:null}` is deliberately treated as **not** a gate on the download path | `py/services/download_manager.py:2000-2003` |
| Version payload sent to the frontend already carries `isPaid` / `paidAccess` | `py/routes/handlers/model_handlers.py:3444-3455` |
| UI: Paid / Early Access badges, `hide_paid_updates` filter | `static/js/components/shared/ModelVersionsTab.js:169-196, 474-545`; `static/js/state/index.js:58-59`; `templates/components/modals/settings/library.html:191-192` |
### 2.2 Upstream CivitAI — what is and is not public
| Fact | Evidence |
| --- | --- |
| Public v1 API intentionally strips pricing: `paidAccess` is reduced to `{permanent, endsAt}`. Source comment: *"Omits terms (pricing belongs to the purchase flow)"* | `~/code/civitai/src/server/services/paid-access.service.ts:640-652`; verified live: `/api/v1/models` and `/api/v1/model-versions/{id}` return exactly that shape |
| The internal tRPC route is **not** usable anonymously. `isAcceptableOrigin` rejects non-site origins with `401 "Please use the public API instead"` | `~/code/civitai/src/server/trpc.ts:120-128`; `acceptableOrigin` = `!isProd \|\| isBearerAuth \|\| isAllowedOriginRequest(req)` (`createContext.ts:43`), and the origin check is header-based (`src/server/utils/origin-helpers.ts:27-32`). **Live probe warning:** an anonymous tRPC GET can return `200` when Cloudflare serves a cached copy (`cf-cache-status: HIT`); uncached it is `401`. Do not build on this path. |
| **The price *is* publicly reachable inside the public model page.** The SSR payload embeds the site's own `model.getById` result, including full `terms` | `https://civitai.com/models/<id>` and `https://civitai.red/models/<id>` → `<script id="__NEXT_DATA__" type="application/json">` → `props.pageProps.trpcState.json.queries[*]` where `queryKey[0] == ["model","getById"]` → `state.data.modelVersions[*].paidAccess` |
| Observed payloads (anonymous, no cookies) | version 3379626 → `{terms:{download:{price:5000},generation:{price:100,trialLimit:5}}, endsAt:null, sale:null}`; version 3380114 → `download.price 125` `acceptsBlueBuzz:true` `endsAt:2026-10-10`; another → `200` |
| Per-generation licensing fees are already public and official | `GET /api/v1/model-versions/mini/{id}` (`MixedAuthEndpoint`, anonymous 200) exposes `fees` / `freeTrialLimit` — `~/code/civitai/src/pages/api/v1/model-versions/mini/[id].ts:455` |
| Webhooks cannot carry price changes | `updated-model` is driven by `Model.lastVersionAt` (fires only on new versions) and its select has no `paidAccess`: `~/code/civitai/src/server/webhooks/model.webooks.ts:88-96`, `~/code/civitai/src/server/selectors/model.selector.ts:15-36` |
| Prevalence (sampled newest 600 models / 4634 versions via public API cursor): **90 gated (1.9%), 87 permanent vs 3 timed** | live scan, 2026-xx; dominated by permanent gates, so "EA expiry" alone covers ~3% of gated versions |
### 2.3 Consequences for the design
* Threshold alerts on download price require **one new data source**: the model page payload. It is
public, but it is page data — so it must be isolated, opt-in, cheap, and fail-open.
* Request budget is small because only gated models need it: ~2% of models, one extra ~200 KB
request per gated model per price TTL.
* "Becomes free" and "gained a gate" need no new data source at all and should ship first.
* One page fetch returns **all** versions of a model, so prices cost one request per model, not per
version.
## 3. Data source decision
**Chosen: `__NEXT_DATA__` of the public model page**, fetched with the existing HTTP stack.
Rejected alternatives:
* *Internal tRPC* — origin-gated; passing it means forging `Origin` or relying on bearer auth against
an undocumented endpoint whose own error message says to use the public API. Not worth the
stability/compliance risk for a convenience feature.
* *Official v1 API as-is* — no price field, by design. Track it as an upstream request (§8), not as a
dependency.
* *`mini/{id}` `fees`* — useful extra signal for per-generation fees, but it is not the download
price and does not cover the gate.
## 4. Design
### 4.1 Schema (all additive, in the existing update DB)
`model_update_versions` gains:
```sql
ALTER TABLE model_update_versions ADD COLUMN price_buzz INTEGER; -- effective (sale-adjusted) download price
ALTER TABLE model_update_versions ADD COLUMN list_price_buzz INTEGER; -- undiscounted download price
ALTER TABLE model_update_versions ADD COLUMN generation_price_buzz INTEGER;
ALTER TABLE model_update_versions ADD COLUMN accepts_blue_buzz INTEGER NOT NULL DEFAULT 0;
ALTER TABLE model_update_versions ADD COLUMN price_sale_ends_at TEXT;
ALTER TABLE model_update_versions ADD COLUMN price_checked_at REAL; -- last *successful* price fetch
ALTER TABLE model_update_versions ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0; -- last computed alert hit, for edge detection
```
Adding a column to this table requires touching **all eight** enumerations (missing one silently
drops data or breaks reads):
1. dataclass `ModelVersionRecord` — `py/services/model_update_service.py:65-84`
2. `_SCHEMA` `CREATE TABLE` — `:300-330`
3. `_apply_migrations` column dict — `:554-580`
4. `_migrate_model_update_versions_primary_key` `target_columns` + `defaults` — `:680-745` (**easy to
miss; omitting it loses the new columns whenever the PK migration runs**)
5. `_build_record_from_remote` — `:1630-1665`
6. `_extract_single_version` — `:1740-1795`
7. `_get_records_bulk` SELECT + row→record mapping — `:1945-1990`
8. `_upsert_record` INSERT — `:2028-2066`
`price_checked_at` lives on the version row (not the model status row) so a stale price on one
version does not force a re-fetch for its siblings; the fetch itself is per model.
### 4.2 Parser contract
New pure function, unit-testable without network:
```
parse_model_page_prices(html: str) -> dict[int, dict] | None
```
* Locate `<script id="__NEXT_DATA__" type="application/json">…</script>`; `json.loads` it.
* Walk `props.pageProps.trpcState.json.queries[*]`; pick the entry whose `queryKey[0] == ["model","getById"]`
(the query order is not stable — 7 queries were observed, `getById` first only incidentally).
* From `state.data.modelVersions[*]`, per version emit:
* `price_buzz` = `paidAccess.sale.buyerTerms.download.price` if `sale` present, else
`paidAccess.terms.download.price`
* `list_price_buzz` = `paidAccess.terms.download.price`
* `generation_price_buzz` = `terms.generation.price` (undefined for `{free:true}` / bundled)
* `accepts_blue_buzz`, `price_sale_ends_at`
* no `paidAccess` → the version is ungated: emit an explicit "no price" marker so the caller can
clear stale prices and raise a *became free* event.
* Return `None` (never raise) for: missing script tag, JSON error, shape mismatch, empty
`modelVersions`, or a Cloudflare/challenge page.
### 4.3 Refresh flow and request budget
Hook: `_refresh_single_model` (`py/services/model_update_service.py:1099-1215`), inside the existing
"lock released during network I/O" window, after `fetched_versions` is built:
```
prices_needed = feature_enabled and (any gated version or any version with a stored price)
if prices_needed:
prices = await provider.get_model_prices(model_id) # never raises; None on failure
if prices is not None:
fetched_versions = [apply_price(v, prices.get(v.version_id)) for v in fetched_versions]
# price_checked_at advances only for versions actually present in the payload
```
* Candidate selection uses the gate data already in hand from the bulk API, so no extra call is made
for the ~98% of models with no gate (except models that previously had a price and must confirm
"now free").
* TTL: `price_check_ttl_hours` (default 24 h), independent of the update TTL — a price refresh can be
skipped while the version list is fresh, and vice versa. Force refresh (`force_refresh=True`) also
forces prices.
* Failure behaviour: keep the previous price, do **not** advance `price_checked_at` (so the next
refresh retries), log at debug/info. A `RateLimitError` from the shared gate must remain a skip,
not a hard failure of the update check. After N consecutive parse failures within one refresh,
stop attempting price fetches for the remainder of that run.
* Offline/cooldown: reuse the existing `ConnectivityGuard` path in `downloader.make_request` — no new
connectivity handling.
Client/provider seam:
* `ModelMetadataProvider.get_model_prices(model_id)` — default returns `None` (not abstract, so
CivArchive / SQLite providers are untouched).
* `CivitaiModelMetadataProvider` → `CivitaiClient.get_model_prices(model_id)`.
* Add the pass-through to the composite provider wrapper
(`py/services/model_metadata_provider.py:855-955`) so the rate-limit helper applies.
* `CivitaiClient.get_model_prices`: build the URL with the existing helper
`build_civitai_model_page_url(model_id, host=self._settings.get("civitai_host"))`
(`py/utils/civitai_utils.py:44-66`; `civitai.red` is already a supported page host,
`:10`), fetch with `downloader.make_request("GET", url, use_auth=False, custom_headers={"Accept": "text/html"})`
(returns `str` because the body is not JSON), then call the parser. Cap the body size before
parsing.
### 4.4 Alert semantics
* **Threshold hit**: `price_buzz is not None and price_buzz <= threshold_buzz`. `threshold_buzz = 0`
means "free only, plus any gate removal".
* **Became free**: a version with a stored gate/price now has no `paidAccess` (from the bulk API
and/or the page payload).
* **Newly gated**: a version that had none now has `paidAccess`.
* Edge detection uses `price_alert_state` plus the previous `paid_access` JSON, compared in
`_build_record_from_remote` where `existing_map` is already built
(`py/services/model_update_service.py:1636-1665`). Do not put this in `has_update`: it is a
different question, and `has_update` is filtered by the hide-* settings.
* `ModelUpdateRecord` gains a **non-persisted** field (e.g. `events: list | None = None`) carrying
`{version_id, kind: "price_drop"|"became_free"|"new_gate", price_buzz, previous_price_buzz}`.
`_upsert_record` ignores it; `_get_record` leaves it `None`.
### 4.5 Settings and routes
New settings (defaults in `DEFAULT_SETTINGS`, `py/services/settings_manager.py:68`;
`settings.json.example` stays minimal per repo policy; frontend defaults in
`static/js/state/index.js:58`):
| Key | Default | Meaning |
| --- | --- | --- |
| `price_tracking_enabled` | `False` | master switch; off means no model-page fetches at all |
| `price_alert_threshold_buzz` | `0` | alert when effective price ≤ this; 0 = free only |
| `price_check_ttl_hours` | `24` | price freshness window |
Routes (all under the existing model route registrars, `POST` + `GET` because the companion
extension is GET-only per `AGENTS.md`):
* `GET /api/lm/models/price-alerts` — aggregate over the update DB:
`{modelId, modelType, versionId, versionName, priceBuzz, listPriceBuzz, thresholdBuzz, isFree}`.
* Prices themselves need no new read route: they ride along in the existing version payload
(`py/routes/handlers/model_handlers.py:3438-3455` gains `priceBuzz`, `listPriceBuzz`,
`generationPriceBuzz`, `acceptsBlueBuzz`, `priceCheckedAt`, `priceAlert`).
* The existing update-refresh response gains `events` (from §4.4) so the frontend can toast.
### 4.6 UI
* `static/js/components/shared/ModelVersionsTab.js`
* Extend the existing Paid / Early Access badge tooltips with the price when known
("Paid · 5,000 Buzz") — helpers at `:169-196`, badges at `:474-545`.
* New badges: price chip (`paid`/`info` styling) and "Free now" for a became-free transition.
* Filter toggle reusing the `hide_paid_updates` pattern (`:349-400`, plus the settings modal and
`SettingsManager.js:1157-1163`) — e.g. "Show price drops only".
* Optional, cheap win: include the price in the paid-download error text
(`py/services/download_manager.py:1987-2031`, `py/services/use_cases/download_model_use_case.py:35`).
* Strings: add to `locales/en.json`, then `python scripts/sync_translation_keys.py`, then **stop** —
do not translate the other locales (see `docs/i18n-translation-guidelines.md` §7).
## 5. Phased tasks
### P0 — settle gate semantics (prerequisite, small)
- [ ] Decide the meaning of `{permanent:false, endsAt:null}`. The repo currently treats it as *no
gate* (`py/services/download_manager.py:2000-2003`), while upstream `isPaidAccessActive`
(`endsAt == null || endsAt > now`) treats it as *active*. Live sample: version 3372655
currently returns exactly this DTO from `/api/v1/models`.
- [ ] Introduce one shared helper (e.g. `_has_active_gate(paid_access, early_access_ends_at)` in
`py/services/model_update_service.py`) and use it from both the update service and the download
gate, so alerts and download blocking cannot disagree.
- [ ] Unit test for the chosen rule.
- **Why first:** every alert kind inherits the gate-state decision; getting it wrong produces false
"free" alerts.
### P1 — gate-state change detection (no new data source)
- [ ] Emit `new_gate` / `became_free` events from `_build_record_from_remote`; add the non-persisted
`events` field; surface in the refresh response.
- [ ] Keep `has_update` semantics unchanged (regression risk to `hide_paid_updates`,
`hide_early_access_updates`).
- [ ] UI: "Free now" badge + one batched toast per refresh; extend paid/EA tooltips.
- [ ] Tests in `tests/services/test_model_update_service.py` for each transition and for the
no-change case.
- **Acceptance:** a version whose gate lapses is visibly marked free after a refresh, with no new
network calls; existing hide-* filters behave identically.
### P2 — price capture (opt-in)
- [ ] Schema + all eight column touchpoints from §4.1, with a migration test.
- [ ] `parse_model_page_prices` + fixture-based tests (`tests/services/fixtures/…`, one trimmed page
with a timed gate, one with a permanent gate, one malformed/missing payload).
- [ ] `CivitaiClient.get_model_prices` + provider methods + composite pass-through; assert redirect
following and that a non-JSON body is returned as text.
- [ ] Wire into `_refresh_single_model` behind `price_tracking_enabled`, with the failure matrix from
§4.3.
- [ ] Tests: feature off ⇒ zero extra calls; fetch failure ⇒ previous price retained and
`price_checked_at` unchanged; gated-only candidate selection.
- **Acceptance:** with tracking enabled, a refresh on a gated model stores `price_buzz` for all of
its versions from one request; with tracking disabled or offline, refresh behaves exactly as today.
### P3 — threshold alerts and surface
- [ ] Settings + defaults (§4.5) and the settings-modal toggles/inputs.
- [ ] Threshold evaluation + `price_alert_state` edges → `price_drop` events.
- [ ] `GET /api/lm/models/price-alerts` aggregate endpoint.
- [ ] Version payload gains the price fields; UI price chip, "Free now" badge, "price drops only"
filter; optional price in the paid-download error.
- [ ] Locale keys + `scripts/sync_translation_keys.py`.
- [ ] Tests: threshold boundary (`<=` vs `<`), Blue Buzz labelling, sale-adjusted vs list price,
alerts for a version already in the library.
- **Acceptance:** a user can set "alert me under 500 buzz", refresh, and see the models that
qualify; nothing alerts when the price rises above the threshold again (state resets).
### P4 — docs and upstream
- [ ] Short feature note in the repo docs; keep this plan's data-source rationale in a code comment
(public page read, no auth or origin spoofing, no internal API).
- [x] Draft the upstream request: add a price field to the public `paidAccess` DTO (§13). Point out the
asymmetry — writes already go through the official v1 endpoint
(`~/code/civitai/src/pages/api/v1/model-versions/early-access.ts`, sharing
`updateModelVersionPaidAccessSchema`) while reads withhold it deliberately. If it lands, the
page scraper becomes an optional fallback rather than the only path.
## 6. Test plan summary
* `pytest tests/services/test_model_update_service.py` — migrations, extraction, transitions,
threshold logic, fetch-failure matrix, feature-off path.
* New parser tests with HTML fixtures (no network in tests).
* `npm run test:js` for badge/helper changes in `ModelVersionsTab`.
* Manual UI verification by the user (per repo policy — no browser automation for layout).
* No live CivitAI calls in the test suite.
## 7. Risks and mitigations
| Risk | Mitigation |
| --- | --- |
| Page payload format changes (Next.js is migrating away from `__NEXT_DATA__` to the flight payload) | Parser is a single pure function behind a provider method; fixture test fails loudly at build time; parse failure is non-fatal and keeps the last known price |
| Cloudflare / UA sensitivity (verified: `civitai.com`, `civitai.red` and even no-UA all returned the payload for the sampled pages) | Reuse `downloader` (proxy, rate-limit coordinator, connectivity guard); detect challenge/absence and back off; never retry in a tight loop |
| Traffic/size (~200-270 KB per model) | Gated models only (~2%), one request per model, `price_check_ttl_hours`, force-refresh only on user action |
| False "free" alerts from gate-semantics ambiguity | P0 helper + explicit tests before any alert ships |
| Misleading price (list vs sale-adjusted; Blue Buzz is the same number but non-withdrawable) | Store both list and effective price; tooltip states the unit and Blue Buzz acceptance |
| Alerts are pull-only (no scheduler, no push channel today) | State it in the UI copy; treat background push as a separate follow-up, not a silent gap |
## 8. Out of scope / follow-ups
* Purchasing or downloading gated content.
* Generation-fee tracking beyond displaying `generation_price_buzz` (the public `mini/{id}` `fees`
field could feed a richer view later).
* A background/notification channel; today alerts appear when the user refreshes or opens the panel.
* Waiting on CivitAI to expose prices; P1–P3 stand alone.
## 9. Open questions
1. `{permanent:false, endsAt:null}` — gate or not? (P0; affects download blocking too.)
2. Should the threshold apply to versions already in the library (watching for a future re-buy) or
only to versions the user does not own?
3. Is a per-model threshold override needed in v1, or is one global threshold enough?
---
## 10. What shipped (P0–P3)
### 10.1 Answers to the open questions
1. **`{permanent:false, endsAt:null}` is a gate.** Decided by evidence, not preference: the public v1
API returns `null` for lapsed gates (`toPublicPaidAccessDto` filters on `isPaidAccessActive`), so a
non-null DTO is always an *active* gate; on the live model 1802980 that version reports
`canDownload: false` while its lapsed-tombstone siblings report `true`. The previous rule dropped
it, which is exactly the #1060 class of bug.
2. The global threshold applies to **every** gated version the update service tracks, in library or
not — the user may be watching a version they intend to buy later.
3. One global threshold in v1; a per-model override is still open.
### 10.2 Files and seams
| Area | Where |
| --- | --- |
| Shared gate semantics (`normalize_paid_access`, `is_gate_active`, `is_early_access_deadline_active`) | `py/utils/paid_access.py` (new) |
| Model page price parser (`parse_model_page_prices`) | `py/utils/civitai_page_prices.py` (new) |
| Price fetch | `CivitaiClient.get_model_prices` (`py/services/civitai_client.py`) |
| Provider seam | `ModelMetadataProvider.get_model_prices` default None; CivitAI impl; fallback + rate-limit wrappers (`py/services/model_metadata_provider.py`) |
| Columns, migration, transition + threshold logic, alerts query | `py/services/model_update_service.py` |
| Version payload, refresh `events`, `GET /api/lm/{type}/updates/price-alerts` | `py/routes/handlers/model_handlers.py`, `py/routes/model_route_registrar.py` |
| Badges, price chip, gate/price toast | `static/js/components/shared/ModelVersionsTab.js`, `static/js/utils/updateCheckHelpers.js`, `static/css/components/lora-modal/versions.css` |
| Settings | defaults in `py/services/settings_manager.py`; UI in `templates/components/modals/settings/library.html` + `static/js/managers/SettingsManager.js`; client defaults in `static/js/state/index.js` |
| API client | `getPriceAlerts` in `static/js/api/baseModelApi.js` + `priceAlerts` endpoint in `static/js/api/apiConfig.js` |
### 10.3 Deviations from the plan
* **`gate_lapsed_at` column added** (8th column): a transient event would leave a "became free"
marker invisible minutes later, so the lapse timestamp is persisted and the UI shows a `Free Now`
badge with it.
* **Alerts are edge-triggered, and first sight is silent.** When a price first appears under the
threshold, `price_alert_state` is stored and the version shows up in `price-alerts` immediately,
but no toast fires — otherwise switching the feature on would announce every cheap version in the
library at once. The toast fires when a price *crosses* down through the threshold.
* **No dedicated alerts panel.** The surfaces are: price chip + `Free Now` badge in the versions tab,
one combined toast per update check, and the `price-alerts` endpoint (GET, so the companion
extension can use it). A browse surface over the endpoint is a follow-up.
* **Prices are only fetched for models with a gate** (~2% of a typical library), one page request per
model, on `price_check_ttl_hours`, and immediately when the gate DTO changes.
* **The paid-download warning message does not include the price**: that path reads the model's
CivitAI metadata, which has no price, and looking one up would add a request to a failure path.
Left as a follow-up.
### 10.4 Test coverage added
* `tests/utils/test_paid_access.py` — gate shapes, activity, timestamps.
* `tests/utils/test_civitai_page_prices.py` + `tests/utils/fixtures/civitai_model_page_paid.html` —
parser, including sale-adjusted vs list price and lapsed tombstones.
* `tests/services/test_model_update_service.py` — migration from a pre-feature DB, column round-trip,
transitions, price capture on/off, failure retention, TTL, threshold edges, alerts query.
* `tests/services/test_civitai_client.py` — page fetch happy path, unusable payloads, rate limits.
* `tests/routes/test_model_update_handler.py` — alerts endpoint, gate events for non-updating records.
---
## 11. P5 — Price alerts panel
**Goal:** a single surface that answers "what got cheaper / became free, and can I act on it".
**Status:** **P5a implemented** (see §11.9); P5b (inline threshold editing, *Refresh prices*, type
chips, ignore action) and P5c (polish) are not.
**Naming:** called P4a–c in earlier discussion; renumbered to **P5** because §5 already uses P4 for
"docs and upstream".
**Owner decision:** the grid-level "price alert only" filter is **out of scope**. The panel is the
only new browsing surface; the existing per-version badges and the update-check toast stay as they
are. Consequence: the panel carries the "act on it" affordances itself (§11.2), and discoverability
rests on the bell badge plus the controls-dropdown entry (§11.1 D8).
### 11.1 Locked decisions
| # | Decision | Why / evidence |
| --- | --- | --- |
| D1 | Host it as a **third tab in the existing notification bell modal**, driven by `UpdateService` | `templates/components/modals.html` is included by `templates/base.html:75`, so the bell exists on every page; `UpdateService` already has `toggleUpdateModal()`, `switchNotificationTab()`, tab badges, arrow-key tab navigation and a list/empty-state pattern (`renderRecentBanners`) |
| D2 | One **global** endpoint `GET /api/lm/price-alerts`, registered **once** in `MiscRoutes` | The update DB is one file per library shared by all model types (`cache/model_update/<library>.sqlite`), and `ServiceRegistry.get_model_update_service()` returns one shared instance (`py/services/service_registry.py:153`). `get_price_alerts` only filters on `s.model_type`, so `model_type=None` is all types in one query. Adding it to `COMMON_ROUTE_DEFINITIONS` would bind the same path once per model type (4×) |
| D3 | **Compare the threshold at read time** (`WHERE v.price_buzz <= ?`); keep `price_alert_state` for the toast edge only | As shipped, panel membership only changes after a refresh, so editing the threshold in the panel would look broken |
| D4 | New column `price_alert_since REAL` | `price_alert_state` is a boolean; "dropped 3 days ago" and an unread count both need the moment the state flipped, which the existing edge detection already knows |
| D5 | Unread state is **client-side** (`localStorage` watermark compared against `price_alert_since`) | No per-user read-state table and no sync logic; the worst case is a conservative badge on a second browser |
| D6 | Row actions: **CivitAI** always; **Open** only when the payload resolved a local file path | `showModelModal(model, modelType)` needs a local metadata object and re-fetches by `file_path` (`static/js/components/shared/ModelModal.js:343-361`); a gated version the user does not own has no local file |
| D7 | **Refresh prices** must bypass the price TTL | Prices stay fresh for `price_check_ttl_hours` (24 h by default), so without a force flag the button would appear to do nothing |
| D8 | **Two non-permanent entry points**, both opening the bell on the Price alerts tab: the **updates dropdown** in the controls bar, and the **global context menu** (right-click on empty space) | The controls dropdown already hosts `checkUpdatesMenuItem` (`templates/components/controls.html:114-126`) and costs no layout space. The global menu is the established home for library-wide occasional actions and already holds the sibling `check-model-updates` (`templates/components/context_menu.html:185`); it has only 7 items and existing hide/separator machinery (`GlobalContextMenu.showMenu` / `_updateSeparatorVisibility`). The **model-card menu is deliberately left alone** — it already has 20 items, and P5b's *Refresh prices* covers the per-model case |
| D9 | Bell badge shows the **unread alert count**; when there are none it keeps today's behaviour (dot for app updates). The same cached count is appended to the global context menu item (`Price alerts (3)`) | Otherwise a price drop is invisible until the user opens the bell. The count is fetched once on init **only when `price_tracking_enabled`**, so users who never enable the feature pay nothing |
| D10 | The global context menu item is **always visible** on model pages (hidden on recipes, like its siblings) | The panel's disabled state is the explanation plus a deep link into Settings; hiding the entry would make the feature undiscoverable for exactly the users who have not enabled it yet |
### 11.2 Information architecture
```
┌ Price alerts [Refresh prices] ┐
│ Threshold: 500 Buzz (click to edit) │
│ [ Under threshold ] [ Became free ] Type: All · LoRA · … │
├──────────────────────────────────────────────────────────────────────┤
│ ▸ Glorious Art · Checkpoint · 2 versions │
│ Alpha · 250 Buzz (was 500) · Blue Buzz OK │
│ EA until Oct 10 · Not in library · dropped 3 days ago │
│ [CivitAI] [Open] [Ignore] │
│ ▸ Eira Kishida · LoRA · 1 version │
│ 125 Buzz · In library · dropped today [CivitAI] [Open] │
└──────────────────────────────────────────────────────────────────────┘
```
* Segments cover both halves of the FR: `Under threshold` (price at or below the threshold) and
`Became free` (versions with `gate_lapsed_at`, no price).
* Grouped by model (a model often has several versions at the same price), sorted cheapest first
then most recently crossed; the type chips filter client-side over the loaded list.
* Row: effective price with the list price struck through when on sale, Blue Buzz note, EA end date,
in-library marker, and "dropped X ago" from `price_alert_since`.
* Actions: open on CivitAI, open the local model modal (only when a file path was resolved), ignore
this version (reuses `setVersionUpdateIgnore`).
### 11.3 Data contract
```
GET /api/lm/price-alerts?limit=200
{
"success": true,
"enabled": true, // price_tracking_enabled
"thresholdBuzz": 500,
"newestCheckedAt": 1791039694.5, // staleness copy ("prices last checked X ago")
"alerts": [
{
"modelId": 2981320, "modelType": "checkpoint",
"modelName": "Glorious Art", // best-effort, from the scanner cache
"versionId": 3379626, "versionName": "Alpha",
"kind": "below_threshold", // or "became_free"
"priceBuzz": 250, "listPriceBuzz": 500,
"acceptsBlueBuzz": true, "priceSaleEndsAt": null,
"priceAlertSince": 1791000000.0,
"gateLapsedAt": null,
"earlyAccessEndsAt": null, "isPaid": true, "isEarlyAccess": false,
"isInLibrary": false,
"filePath": null, // set only when resolvable
"civitaiUrl": "https://civitai.com/models/2981320?modelVersionId=3379626"
}
]
}
```
One list with a `kind` discriminator (not two lists) so "All" needs no second request. `civitaiUrl`
is built server-side with the existing `build_civitai_model_page_url` so `civitai_host` stays the
single source of truth.
### 11.4 States (all three must be designed, not just the happy path)
1. **Tracking off** — explain that CivitAI publishes no price in its public API and that the feature
reads the model page, plus a button that opens Settings → Library.
2. **On, nothing matching** — "nothing under N Buzz right now", with the threshold editable inline.
3. **Stale / offline / rate-limited** — keep the last known list, add a "prices last checked X ago"
line and a retry; never blank the panel.
### 11.5 Tasks
**P5a — panel usable end to end**
1. `py/services/model_update_service.py`
* `get_price_alerts(model_type=None, *, threshold_buzz, limit=200)` — optional type filter, live
threshold comparison, `kind`, `price_alert_since`; keep the shipped per-type call site working.
* `price_alert_since` column through all eight enumerations (§4.1), set on the `0 → 1` edge and
cleared on `1 → 0` inside `_build_record_from_remote`.
2. `py/routes/misc_route_registrar.py` + `py/routes/handlers/misc_handlers.py` — register
`GET /api/lm/price-alerts` **once**, using `ServiceRegistry.get_model_update_service()` and the
settings service; resolve `modelName` / `filePath` best-effort per model type from the scanner
caches (`version_index[version_id]` → `file_path`, `file_name`; item `model_name` with a
`file_name` fallback), and omit them when resolution fails.
3. `templates/components/modals/update_modal.html` — third tab + panel skeleton (segments, threshold
row, list container, empty/disabled blocks), reusing `data-notification-tab` /
`data-notification-panel`.
4. `static/js/managers/UpdateService.js` — `renderPriceAlerts()`, panel fetch on open, tab badge
count, threshold display, row rendering and actions, `localStorage` watermark; extend
`updateTabBadges()` and `switchNotificationTab()`.
5. `static/js/api/apiConfig.js` + a small fetch helper — the endpoint is global, so it does not
belong in the per-model-type `endpoints` map.
6. `templates/components/controls.html` + `static/js/components/controls/PageControls.js` — the
controls-dropdown item (D8).
7. `templates/components/context_menu.html` + `static/js/components/ContextMenu/GlobalContextMenu.js`
— the global-context-menu item (D8): one template entry, the recipes-page hide list in
`showMenu()`, one `case` in `handleMenuAction`, and the optional `(N)` count from the cached
alert count. Both entries call one shared `openPriceAlertsPanel()` helper.
8. `locales/en.json` + `python scripts/sync_translation_keys.py`.
*Acceptance:* with tracking on and one refresh done, the bell shows a count, the tab lists the
versions with prices, CivitAI opens the right page, Open appears only for in-library models and opens
the modal, all three states render, and it works from every page type.
**P5b — actions and polish**
* Inline threshold editing (read-time comparison makes it instant) and **Refresh prices** with a
`force_price_refresh` flag on the existing refresh endpoint (or a dedicated
`POST /api/lm/price-alerts/refresh`).
* Type chips and a "only versions I do not own" toggle (this is open question 2 from §9).
* Ignore-this-version action.
**P5c — optional**
* Thumbnails from the scanner cache, "unread only", focus-management pass on the new tab, and a
deep link from the panel into the settings section.
### 11.6 Tests
* `tests/services/test_model_update_service.py` — `model_type=None` spans types; live threshold
parameter; `price_alert_since` set/cleared on the edge.
* `tests/routes/` — the global route is registered exactly once and returns the documented shape;
`filePath` resolution is best-effort (mock scanner; omit on failure).
* `tests/frontend/` — vitest for `renderPriceAlerts` covering rows, both segments, the three states
and the badge count with a mocked fetch.
* Manual eyeball for layout, per the repo's UI verification policy.
### 11.7 Risks
| Risk | Mitigation |
| --- | --- |
| The bell modal is nominally about app updates; a third tab changes its character | Clear labelling, and only count/badge when the feature is on; the app-update dot behaviour is unchanged when there are no alerts |
| Resolving local file paths pulls scanner caches into an app-wide route | Best-effort with `try/except`, one `get_cached_data()` per type (in-memory), omit the field and hide the Open button when it fails |
| `price_alert_since` is another migration | Same eight-touchpoint discipline as §4.1, covered by the existing migration test |
| A global route in `COMMON_ROUTE_DEFINITIONS` would bind 4× | Register it in `MiscRoutes` instead (D2) |
| Menu bloat / entry sprawl | Two entries maximum (D8), both calling one helper; the 20-item model-card menu is explicitly untouched |
| Panel data goes stale between refreshes | Show `newestCheckedAt`; the refresh action (P5b) makes it explicit rather than silent |
### 11.8 Open questions for the owner
1. **Badge policy** — count unread alerts on the bell (recommended, D9), or leave the bell alone and
put the number only on the tab?
2. **Unread at all** — the `localStorage` watermark (recommended, D5), or simply "current matches"
with no read state?
3. **Threshold editing inside the panel** — convenient, but it silently changes a global setting from
a surface the user opened just to look.
4. **"Open in library"** — worth the scanner-cache coupling (D6), or should the row offer only
"CivitAI" plus a copy-link action?
5. **Thumbnails** — skip for weight (recommended) or show them?
6. **Entry visibility when tracking is off** — always show the global-context-menu item
(recommended, D10: the panel's disabled state educates and deep-links to Settings), or hide it
until the feature is enabled for a cleaner menu?
7. **Count in the menu label** — `Price alerts (3)` using the cached count (recommended, D9), or a
plain label with the number only on the bell/tab?
### 11.9 What shipped (P5a)
Answers to §11.8: badge counts unread alerts (D9), unread uses the `localStorage` watermark (D5),
threshold editing is **display-only in P5a** (editing is P5b), "Open in library" ships with the
best-effort path resolution (D6), no thumbnails, the context-menu item is always visible (D10), and
the menu label carries the count (D9).
| Area | Where |
| --- | --- |
| `price_alert_since` column through all eight enumerations, set on the `0 → 1` edge, preserved while the alert stands, cleared when the price rises | `py/services/model_update_service.py` |
| `get_price_alerts(model_type=None, *, threshold_buzz, limit)` — read-time threshold, `kind`, one list across types; `newest_price_checked_at()` | `py/services/model_update_service.py` |
| Global endpoint `GET /api/lm/price-alerts`, registered once | `py/routes/misc_route_registrar.py`, `py/routes/misc_routes.py`, `PriceAlertsHandler` in `py/routes/handlers/misc_handlers.py` |
| Third bell tab, panel skeleton, segments, three states | `templates/components/modals/update_modal.html`, `static/css/components/modal/update-modal.css` |
| Loader, renderer, unread watermark, badge, `openPriceAlertsPanel()` | `static/js/managers/UpdateService.js` |
| Entry points (controls dropdown + global context menu, count in the label) | `templates/components/controls.html`, `static/js/components/controls/PageControls.js`, `templates/components/context_menu.html`, `static/js/components/ContextMenu/GlobalContextMenu.js` |
Deviations and decisions made while implementing:
* **`became_free` is reported even while price tracking is off.** It needs no price data, so the
panel shows the "tracking is off" explanation *and* whatever became free instead of hiding the
half of the feature that already works.
* **`price_alert_since` is also set on first sight** of an already-cheap version. The toast stays
silent (edge-triggered, §10.3), but the count is non-zero, which is what invites the user into the
panel after enabling the feature.
* **The per-type frontend client method was removed** (`baseModelApi.getPriceAlerts` and the
`priceAlerts` entry in `apiConfig.js`): with the global endpoint it was dead code. The per-type
**backend** route stays for the companion extension and a possible future grid filter.
* **`openPriceAlertsTab()` exists because `toggleUpdateModal()` closes an open bell** — an entry
point calling it unconditionally would dismiss the modal instead of switching tabs.
* **Local context uses the existing indexes** (`cache.model_id_index` for `model_name`,
`cache.version_index` for `file_path`/`file_name`), so it is O(1) per row; every failure just
omits the fields and the row loses its "Open" button.
Verification: `pytest` 3652 passed / 7 skipped, `npm run test:js` 1444 passed, plus a sandboxed
standalone server run that seeded the update DB and confirmed the payload shape, the `kind`
split, the `civitaiUrl`, and that changing `price_alert_threshold_buzz` through `POST /api/lm/settings`
changes panel membership immediately with no refresh.
### 11.10 Known limitation: mature (NSFW) model prices, and the host fallback
End-to-end verification against the live site found that the page fetch was broken for a whole class
of users: **the hosts are not interchangeable**, and the user's `civitai_host` preference was
silently fatal.
Measured with the app's own HTTP stack (aiohttp) and with httpx, browser User-Agent in both cases:
| Target | civitai.com | civitai.green | civitai.red |
| --- | --- | --- | --- |
| Anonymously visible model page | 200, prices parse | 200, prices parse (same bytes as .com) | **403 Cloudflare challenge** |
| Mature (NSFW) model page | 404 | 404 | **403 Cloudflare challenge** |
* `civitai.red` refuses non-browser clients outright — User-Agent, `Accept-Language`, `Sec-Fetch-*`
and switching HTTP library all make no difference. (An earlier manual check with `curl` passed by
luck of TLS fingerprint, which is why this was missed: it was a false positive.)
* A real user on `civitai_host=civitai.red` therefore captured **zero** prices
(`price_checked_at = 0` in their update DB) even with tracking enabled.
* Mature models are hidden from anonymous visitors on `.com`/`.green` and only served by `.red`, so
**no host can currently read their price.** The public API cannot help: it trims `paidAccess` to
`{permanent, endsAt}` by design, and the public `mini/{id}` endpoint exposes only per-generation
`fees`.
What P5a now does about it:
* **Host fallback** (`CivitaiClient.get_model_prices`): the configured host is tried first, then the
others (`civitai_page_host_candidates`), first parseable payload wins. The host that worked is
remembered, and a host that refuses outright (403) is parked for 15 minutes — a host-wide failure
must not cost three requests per mature model in the library. A 404 is model-specific and does
**not** park the host.
* **Honest "unavailable" state**: `price_check_attempted_at` separates "we tried and could not read
a price" from "we never looked". Gated versions in that state show a muted `Price unavailable`
badge in the versions tab, and the panel reports `unavailableCount`.
* **Diagnostics**: a host refusal is warned once per host per TTL, and a model with no price source
logs the per-host reasons (404 vs challenge) instead of failing silently at debug level.
Recorded options for mature models, deliberately **not** implemented:
1. **Internal tRPC with the user's API key** (`modelVersion.getById` on `.red`): the route is a
`publicProcedure` with `requiredScope: ModelsRead`, and `isBearerAuth` satisfies
`acceptableOrigin`, so the user's own key would work and `.red`'s `/api` paths are not challenged.
Rejected as the default because the endpoint is undocumented and its own 401 message says to use
the public API. If ever wanted, it belongs behind an off-by-default setting.
2. **Extension-assisted fetch**: `lm-civitai-extension` runs inside the user's browser, so it has
both the Cloudflare clearance and the login session needed to read mature pages. This is the only
route that would work without an undocumented API, but it is a cross-component design of its own.
**Strengthened upstream ask:** the public API should expose the price. The argument is no longer
"convenience" — the page route is demonstrably unreliable (one host challenges non-browser clients,
the other two hide mature models from anonymous visitors), so a supported field is the only way for
any third-party tool to show prices for the models where creators monetize most.
### 11.11 Price capture is independent of the metadata TTL
Found in a real instance: after enabling price tracking, a normal "Check updates" captured exactly
**one** price out of 718 models. The price capture only ran when the *version list* was re-fetched
(`refresh_succeeded`), so it inherited the metadata TTL: with 24 h metadata and 24 h price TTLs, only
the 6 models whose metadata happened to be stale were ever priced.
The cached record already carries the gate, so the price pass now runs off whichever version list is
available — freshly fetched or stored — and applies the result without touching `last_checked_at`, so
a price-only pass cannot silently extend the metadata TTL. Two related semantics:
* A **failed** attempt (`price_check_attempted_at`) satisfies the price TTL, so a mature model whose
page no host will serve is not retried on every single update check.
* An explicitly **forced** check re-prices within the TTL (`_should_fetch_prices(..., force=True)`).
Verified by copying a real instance's update DB into a sandbox and running a non-forced check:
`bulk metadata fetches: 0` (version lists came entirely from cache) while priced versions went
**1 → 20** and the panel listed 19 alerts.
### 11.12 The empty state has to explain a 0 Buzz threshold
The default threshold is 0 ("only tell me when a version becomes free"), and the settings copy says
so. The panel did not: a real instance with **52 priced paid versions** and an untouched threshold
showed "Nothing is under your price threshold right now" plus a small "Alert threshold: 0 Buzz" in
the corner, which reads as "the feature is broken".
The empty state is now threshold-aware, using a new `pricedCount` in the payload: when the threshold
is 0 and prices are known, it says how many paid versions have a price and where to set a threshold.
The read-time threshold comparison means changing it takes effect immediately — no re-check needed
(measured on a copy of that instance's DB: 0 Buzz -> 0 alerts, 100 -> 44, 500 -> 48, 5000 -> 51).
## 12. Redesign (locked): obtainability of versions you do not own
### 12.1 Why the P5a model was wrong
The owner could not tell, from the UI alone, what "Buzz Price Tracking" enables, what the
"Price alert threshold" number means, or what "Price Alerts" is alerting about. That is not a copy
problem: the implementation exposed **our mechanism** (a page scrape) and **our SQL predicates**
(`price_buzz <= ?`, `gate_lapsed_at IS NOT NULL`) as the user's concepts. Two concrete defects came
out of the same root:
* **The alert population included versions the user already owns.** Neither the event generator nor
the panel query filtered on `is_in_library` (the payload even carried `isInLibrary`). In the
owner's library **28 of 52 gated versions were already downloaded** — more than half of the
"alerts" were about things already on disk. A version you own cannot become cheaper *for you*.
* **A filtered list in a notification surface.** "Price Alerts" was a state list whose contents were
entirely decided by a number stored in Settings, so an empty panel had three indistinguishable
causes (tracking off / threshold 0 / genuinely nothing), and it read as a broken feature.
Two measurements shaped the correction:
* **Permanent prices dominate** (87 permanent vs 3 timed gates in a 600-model sample), so "wait for a
price drop" is a low-frequency proposition, while "wait for early access to end" is high-frequency
and deterministic.
* **Prices cluster at the floor** (44 of the owner's 52 priced versions are 100 Buzz), so
"price <= N" carries little information; the real distinction is *free / soon free / paid*.
Also corrected during the discussion: the **generation fee** is the cost of generating *on the
CivitAI site*. A locally downloaded model generated in ComfyUI has no such fee, so for this tool it
is not a price axis at all (42 of the owner's 52 gated versions carry one — displaying it would
mislead on 80% of paid rows). It is not displayed today and must stay that way.
### 12.2 The corrected information model
The unit is the **version**, and the boundary is **ownership**: information exists only where a
decision exists.
| State of a version **not in the library** | The user's decision | What is shown |
| --- | --- | --- |
| Downloadable for free | download or not | nothing special (the download action already says it) |
| Early access, free on D, buyable for N | **wait until D / pay N now** | `N Buzz - free on D` |
| Early access, free on D, not buyable | wait | `free on D` |
| Permanently paid, price known (N) | buy or skip | `N Buzz` |
| Permanently paid, price unknown | buy or skip, price unknown | `Paid` (no number) |
| **In the library** | none | **nothing** |
The numeric threshold has no place in this table: every decision is categorical (wait / pay / skip),
not numeric.
### 12.3 Locked decisions
* **D1 - No independent surface.** Obtainability is an attribute of the existing update surfaces:
the model card's update badge, the grid's "models with updates" filter, the post-check toast, and
the model modal's version list. No new bell tab.
* **D2 - No numeric threshold anywhere.** The setting, the comparison, and the tests around it go.
* **D3 - Only versions that are not in the library** produce events or badges (`is_in_library = 0`),
enforced in both the event generator and any query.
* **D4 - Owned versions show no download price** (and carry no alert state).
* **D5 - The generation fee is never displayed.**
* **D6 - Models outside the library are out of scope.** Their decision moment is the download/browse
flow; that is a recorded follow-up, not a background feed (the update service only knows local
models, and there is no follow mechanism to build on).
* **D7 - The plumbing stays**: page fetch, host fallback, attempt markers, both TTLs, storage,
parser, `gate_lapsed_at`, and the price columns.
### 12.4 What happens to what shipped in P5a
| Piece | Fate |
| --- | --- |
| `GET /api/lm/price-alerts`, `GET /api/lm/{prefix}/updates/price-alerts`, `PriceAlertsHandler` | **removed** - no surface consumes them; events reach the UI through the refresh response |
| `get_price_alerts`, `newest_price_checked_at`, `count_priced_versions`, `count_unavailable_prices` | **removed** (threshold comparison and the panel's counters) |
| `price_alert_threshold_buzz` (settings, `_price_alert_threshold_buzz`) | **removed** |
| `price_alert_since` | **stop writing**; the column stays (SQLite drops need a table rebuild) and is marked deprecated |
| `price_alert_state` | **kept** - it is the one-shot edge for the "became free" toast |
| Bell tab, panel markup, `.price-alerts*` CSS, `UpdateService` panel code, watermark, the two entry points (controls dropdown, global context menu), their locales | **removed** |
| Version badges | **reworked** to the 12.2 table, ownership-scoped |
| "Price unavailable" badge | **replaced** by `Paid` (the gate is known from the public API; only the number is best-effort, and that is our plumbing, not the user's problem) |
| `priceAttemptedAt` in the version payload | **removed** from the payload (kept internally for the price-TTL retry policy) |
| The "became free" toast | **kept**, reworded, ownership-scoped |
### 12.5 Tasks
**P6a - the core correction (no new surface).**
Backend: ownership-scope the event generator; remove the threshold, the alert query, the counters,
the two routes and the handler; stop writing `price_alert_since`. Frontend: rework the version
badges to the 12.2 table; delete the panel, tab, entry points, watermark and their locales; reword
the toast. Tests: replace the panel/alert suites with a badge-state matrix and ownership-scoping
tests; assert the removed routes are gone.
**P6b - obtainability in the update surfaces.**
Per-model obtainability summary in the model list payload (next to `hasUpdate`) so the card badge can
say `Update - 500 Buzz` / `Update - free on Oct 15`; toast copy that counts the free ones
("3 models have updates, 1 is now free").
**P6c - deferred (recorded, not planned).** A price-drop event (rare by measurement); obtainability
in the download/browse flow for models outside the library (D6).
### 12.6 Verification
Unit tests for the state matrix and the ownership filter; a sandbox run against a **copy** of the
owner's real update DB, where the population must shrink from 52 gated versions to the **24 that are
not in the library**, with the owned ones showing nothing.
### 12.7 What shipped (P6a)
* The two alert-state columns are **gone from the schema** (not merely unused): the dataclass,
`_SCHEMA`, the additive migration table, the primary-key rebuild's column list and defaults, the
bulk `SELECT`, the row mapping and the `INSERT`. A database created by an unreleased build still
has them, so `_apply_migrations` drops them with a native `ALTER TABLE ... DROP COLUMN` (SQLite
3.35+, guarded) and logs it; for everyone else it is a no-op.
* `price_alert_threshold_buzz`, `_price_alert_threshold_buzz`, `_evaluate_price_alert`, the
threshold-crossing event, `get_price_alerts`, `newest_price_checked_at`,
`count_priced_versions`, `count_unavailable_prices`, both alert routes, `PriceAlertsHandler`, its
handler-set slot, its route mapping entry and the service-registry adapter field it needed: all
removed.
* Gate events (and only gate events) are emitted, and only for versions the user does not have -
`isInLibrary` is now the filter rather than a field in the payload.
* Version badges follow the 12.2 table: cost information only for versions not in the library, the
price replaces the redundant `Paid` badge when it is known, `Paid` appears without a number when
it is not, and `Price unavailable` is gone (the gate is certain; only the number is best-effort).
* The bell tab, panel markup, panel CSS, the controls-dropdown and global-context-menu entries, the
unread watermark and their locales are removed; the toast keeps its gate transitions and drops the
price-drop line. The setting is now framed as plumbing ("Show download prices for paid versions")
and keeps only the enable flag and the refresh interval.
Verified: `pytest` 3653 passed / 7 skipped, `npm run test:js` 1444 passed. Against a copy of the
owner's real database the population is 52 gated versions -> **28 owned (now silent) + 24 that the
feature is actually about**; the drop migration logged twice and left no `price_alert_*` column, and
both removed endpoints return 404.
### 12.8 Correction to P6b: the model card cannot carry a price
P6b proposed annotating the model card's update badge with `Update - 500 Buzz` /
`Update - free on Oct 15`. The owner objected that the badge represents a **model** already in the
library while the update is a set of versions, each with its own price and its own early access end
date. The objection is correct, and the data is blunt about it:
* Live model `958009` has **37 gated versions across 6 price points** (1111, 2220, 4440, 8880,
88800, 888888); model `153568` has two gated versions at 600 and 4000.
* In the owner's own library, model `647926` has two early access versions with **two different end
dates**.
So a single price or date on the card would be fabricated, not summarised. It looked plausible only
because that library is uniform (44 of its 52 priced versions sit at the 100 Buzz floor) - an
artefact of floor pricing, not a guarantee.
What is well defined at model level, by construction: counts ("3 updates"), the earliest upcoming
free date (a minimum over the set), a minimum price ("from 100 Buzz", well defined but misleading
when the cheap version is an old one), and uniformity statements only when they are true. "The price
is X" is not among them.
Revised scope: the card badge stays a plain update indicator, and **prices stay in the version list**,
where every row has exactly one subject. The only model-level fact worth considering is the deadline
- "1 of 3 updates becomes free on Oct 15" - because a deadline can change what the user does *before*
opening the model; it is rare in practice (4 unexpired early access versions across 3 models in that
library), so it is optional polish rather than part of the correction.
## 13. Upstream API request
### 13.1 The request as filed (send this)
**Filed:** [civitai/civitai#5384](https://github.com/civitai/civitai/issues/5384) (2026-10-05, as
`willmiao`).
**Title:** `[API Feature Request] Expose Buzz prices for paid / early-access model versions`
**Body:**
> **Ask:** include the Buzz price in the public `paidAccess` DTO - the download price, whether a
> sale is currently discounting it, and whether Blue Buzz is accepted.
>
> **Why:** the API already tells a client *that* a version is gated and *when* early access ends
> (`paidAccess: { permanent, endsAt }`), but not what it costs. In the Buzz economy that is the
> other half of the question users are asking: wait for it to become free, or pay now? A live sale
> or Blue Buzz support is often what decides it.
>
> With the price exposed, an integration like ComfyUI LoRA Manager can show "100 Buzz - free on
> Oct 11" on a version and tell users when a paid version becomes free, instead of sending them to
> the website to check.
>
> Public or authenticated-only - whichever you prefer.
Filing notes: a new issue on `civitai/civitai`, using that repo's `[API Feature Request]` title
convention. No existing issue asks for this (searched `paidAccess`, "download price",
"buzz price API", and every open `[API Feature Request]`).
### 13.2 Internal notes (do NOT paste into the issue)
Kept here because it is what makes the request cheap to grant - but telling a maintainer how to
implement their own service reads as presumptuous, and volunteering how we read prices today is
our business, not theirs. Both were in the first draft and were cut.
* `toPublicPaidAccessDto` (`src/server/services/paid-access.service.ts:649`) already receives a
`PaidAccessRow` carrying `terms: PaidAccessTerms` and `sales?: ModelVersionSaleWindow[]`
(`packages/civitai-buzz/src/paid-access.ts:132`) and returns only `{ permanent, endsAt }`.
* `discountedTerms(terms, sales, now)` (line 426) already resolves what a buyer pays during a sale.
* The write path already accepts the same terms: `/api/v1/model-versions/early-access` validates
`updateModelVersionPaidAccessSchema`, whose `paidAccess.terms` is `modelVersionTermsSchema`.
* So the reads withhold exactly what the writes accept, from a row that already holds it.
Evidence gathered for our own records (also not for the issue):
| Check | Result |
| --- | --- |
| `GET /api/v1/models?ids=646411` | version `1249246` -> `paidAccess: {permanent: true, endsAt: null}`, no price |
| `GET /api/v1/model-versions/1249246` | same, plus `usageControl: "Download"`, `licensingFee: null` |
| Same version on the model page | `downloadPrice 100`, `generationPrice 50`, `acceptsBlueBuzz false` |
| `civitai.red` model pages | Cloudflare challenge (403 for any User-Agent; aiohttp and httpx alike) |
| `civitai.com` / `civitai.green`, mature models | 404 to anonymous visitors, so no readable price source |
### 13.3 Why the request is written that way
* **Lead with the ask and the need; stop.** A maintainer triages on the first lines; the rest is
only read if the ask survives.
* **No implementation guidance.** They know their service. A "here is your one-line change"
section invites either irritation or a correction, and it can be wrong: withholding prices may be
a deliberate product decision (the code comment says pricing belongs to the purchase flow), so the
request has to argue for the *need*, not the diff.
* **No mention of how we read prices today.** It is an internal approach, it shifts the thread from
the feature to our behaviour, and it invites a "please don't do that" that has nothing to do with
the ask.
* **One scope concession, one clause** ("public or authenticated-only"): it removes the most likely
objection without prescribing anything.
* **One precedent clause** ("you already expose whether a version is gated and when early access
ends"): it makes the price look like the natural companion of what is already public.
* **One credibility clause** (naming ComfyUI LoRA Manager as a real integration): it separates the
request from a one-off user wish.
* Target length: ~130 words. Longer drafts read as a specification, which is the failure mode this
section exists to prevent.
@@ -1,7 +1,7 @@
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
# Reconcile 的 Windows 大小写回退分支
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
> **状态**: 已改为 O(1) 索引(2026-10-06),不再需要 Windows 机器验证 | **创建日期**: 2026-09-11
> **相关文件**: `py/services/model_scanner.py`(`_walk_root_for_reconcile` / `_CachedPathLookups`)
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
---
@@ -9,84 +9,70 @@
## 背景
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
~1400 ms 降到 ~120 ms。
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
因此单独记录,留待 Windows 机器上确认。
realpath 全量遍历改成按需触发。清理时留下的唯一可疑点,是 Windows 专属的大小写不敏感回退分支:它排在
精确匹配和 realpath 别名匹配之后,只有**未命中**的文件才会走到,但一旦走到就是 O(文件数 × 缓存条目数)。
---
## 待验证分支(现状)
## 现状(已改造)
`py/services/model_scanner.py` 中 `_reconcile_cache()` 的 walk 循环内:
分支语义保持不变,查找改为 O(1):
```python
# Try case-insensitive match on Windows
if os.name == 'nt':
lower_path = file_path.lower()
matched = False
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
if cached_path.lower() == lower_path:
found_paths.add(cached_path)
matched = True
break
if matched:
continue
if _CASE_INSENSITIVE_PATHS: # 模块级常量,默认 os.name == "nt"
cached_case_match = lookups.match_casefold_path(file_path)
```
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
`lookups` 是 `_CachedPathLookups`:小写索引 `{lower(cached_path): cached_path}` 在第一次未命中时构建一次
(与 realpath 别名索引同样懒构建,并用 `threading.Lock` 护住——walk 现在跑在工作线程里),之后每次未命中
只做一次字典查询。
### 为什么可疑
1. **可能不可达**:Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
3. **没有测试覆盖**:`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
被 `os.name == 'nt'` 短路,无法覆盖)。
`_CASE_INSENSITIVE_PATHS` 提成模块级常量的原因:这条分支在 Linux 上原本被 `os.name == 'nt'` 短路、没有任何
测试覆盖;现在测试可以 monkeypatch 该常量,在 Linux 上真实执行这条分支。
---
## 待办
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
- [ ] **补一个 Windows-only 的回归测试**(`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
- [ ] 把验证结论回填到本文件,并同步更新状态行。
- [x] **消除 O(N×M)**:改成懒构建的小写索引,查询降为 O(1)。
- [x] **补回归测试**:`tests/services/test_model_scanner.py::test_reconcile_case_fold_fallback_is_indexed_not_linear`
(缓存路径与磁盘仅大小写不同、realpath 别名不命中 → 断言条目保留、不重新处理、索引只构建一次)。
- [x] 回填本文件。
- [ ] (可选,纯代码瘦身)在 Windows 上确认 realpath 别名匹配是否已覆盖全部情形;若确认该分支不可达,
可以整体删掉这一层。它现在的成本已经可以忽略,删除不再是性能问题。
---
## 验证方法(Windows)
## 验证方法(可选,Windows)
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
2. **看后端日志判据**:
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`,
且**没有** `Found N new files to process` / `Processing <path>`。
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
(缓存路径被当成了新文件 + 旧条目被删)。
3. **跑测试**:`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
真实执行 `os.name == 'nt'` 分支)。
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
1. 构造"缓存路径与磁盘真实大小写不一致"的场景(改过盘符大小写、迁移过 `settings.json` 与持久化缓存),点 Refresh。
2. 日志判据:`Cache reconciliation completed in X seconds. Added 0, removed 0 models.`,且**没有**
`Found N new files to process` / `Processing <path>`。
3. 跑测试:`python -m pytest tests/services/test_model_scanner.py -k reconcile`。
---
## 已完成(本轮优化,供对照)
## 已完成(历史,供对照)
同一次清理里已经落地并验证的部分(Linux,5 万文件库):
2026-09-11 那轮清理里在 Linux(5 万文件库)验证过的部分:
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`),
零变更且缓存干净时跳过;快照本身含重复路径时仍会自愈。
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`)。
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
`re-processed=0`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
47 项、全量后端 2567 项全部通过。
`re-processed=0`。
---
## 2026-10-06 追加:walk 阶段的并发与进度
同一次改动还做了两件事(与大小写分支无关,但都动到了同一段 walk 循环,故一并记录):
- walk 循环抽成同步函数 `_walk_root_for_reconcile()`,并按设备分组
(`_root_device_key()` / `_group_roots_by_device()`)在工作线程里并行执行:同一设备内的 root 仍按配置顺序
串行(保证目录认领与去重的确定性),不同设备之间才并行。结果回到事件循环后按**配置的 root 顺序**合并,
因此"同一个文件可达多条业务路径时谁胜出"与并发完成顺序无关。
- walk 阶段按 root 广播进度:`_ReconcileWalkTracker` 以「该 root 的缓存条目数」为权重估算进度(真实文件数
只有走完才知道),进度条把 walk 记为 0-50%,新增文件处理阶段顺延为 50-99%,两段之间不会回退。
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "Weniger als eine Minute verbleibend",
"minutes": "~{minutes} Min. verbleibend",
"hours": "~{hours} Std. {minutes} Min. verbleibend"
}
},
"walkFiles": "{count} Dateien"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "Prüfe auf {type}-Updates...",
"success": "{count} Update(s) für {type} gefunden",
"none": "Alle {type} sind auf dem neuesten Stand",
"error": "Fehler beim Prüfen auf {type}-Updates: {message}"
"error": "Fehler beim Prüfen auf {type}-Updates: {message}",
"gateEvents": {
"becameFree": "{count} Version(en), die Sie nicht besitzen, sind jetzt kostenlos",
"newGate": "{count} Version(en) sind jetzt kostenpflichtig"
}
},
"cleanupExampleImages": {
"label": "Beispielbild-Ordner bereinigen",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"priorityTags": "Prioritäts-Tags",
"versionScope": "Update-Markierungen",
"priceTracking": "Buzz-Downloadpreise",
"exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung",
"metadata": "Metadaten",
@@ -759,6 +765,13 @@
"label": "Bezahlte Updates ausblenden",
"help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an"
},
"priceTracking": {
"help": "Die öffentliche API von CivitAI veröffentlicht keine Downloadpreise, daher werden sie von der Modellseite gelesen - eine zusätzliche Anfrage pro bezahltem Modell, aktualisiert im unten stehenden Intervall. Preise werden nur bei Versionen angezeigt, die Sie noch nicht haben.",
"enabled": "Downloadpreise für bezahlte Versionen anzeigen",
"enabledHelp": "Liest den Downloadpreis von der Modellseite, damit bezahlte Versionen und Versionen mit frühem Zugriff ihren Preis und das Ende des frühen Zugriffs zeigen",
"refreshHours": "Aktualisierungsintervall für Preise (Stunden)",
"refreshHoursHelp": "Wie lange ein gespeicherter Preis gültig bleibt, bevor er erneut gelesen wird"
},
"licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
@@ -905,7 +918,10 @@
"refresh": {
"title": "Modelliste aktualisieren",
"full": "Cache neu aufbauen",
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen.",
"scopeSection": "Nur einen Ordner scannen",
"rootOffline": "Offline",
"rootModels": "{count} Modelle"
},
"fetch": {
"title": "Metadaten von CivitAI abrufen",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. 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.",
"missingTitle": "Ordner nicht gefunden",
"missingMessage": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"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.",
"protectedTitle": "Ordner ist geschützt",
"protectedMessage": "Dieser Ordner enthält die Rezept-Bibliothek und kann nicht aus LoRA Manager heraus gelöscht werden.",
"busyTitle": "Eine Löschung steht noch aus",
"checking": "Ordnerinhalt wird geprüft...",
"confirm": "Ordner löschen"
"confirm": "Ordner löschen",
"confirmMulti": "{count} Ordner löschen",
"deleting": "Wird gelöscht...",
"keptNote": "Nicht angehakte Kopien lassen diesen Ordner in der Seitenleiste bestehen."
},
"deleteFolderResult": {
"success": "Ordner \"{name}\" gelöscht",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "Dieser Ordner enthält noch {count} Modelldatei(en). Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"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",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
"missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"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",
"partial": "{count} von {total} Kopien gelöscht — {failed} konnten nicht gelöscht werden. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut."
},
"renameFolder": "Ordner umbenennen",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "Ein Ordner mit diesem Namen ist hier bereits vorhanden",
"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 Umbenennen von Ordnern wird auf dieser Seite nicht unterstützt",
"noRoot": "Es ist kein Modell-Stammverzeichnis konfiguriert"
"missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
"symlink": "Dieser Ordner ist ein symbolischer Link; er kann nicht über die Seitenleiste umbenannt werden."
},
"folderRoot": {
"chooseTitle": "Dieser Ordner existiert in mehreren Modell-Stammverzeichnissen",
"symlinkOption": "symbolischer Link",
"rootsHint": "Wählen Sie die Modell-Stammverzeichnisse, aus denen dieser Ordner gelöscht werden soll:",
"checkingStatus": "wird geprüft...",
"okStatus": "keine Modelle",
"notEmptyStatus": "enthält {count} Modelldatei(en) — löschen oder verschieben Sie diese zuerst",
"notEmptyExcludedStatus": "enthält {count} Modelldatei(en), davon {excluded} von der Bibliothek ausgeschlossen",
"busyStatus": "eine Löschung steht noch aus",
"missingStatus": "existiert nicht mehr auf dem Datenträger",
"symlinkStatus": "symbolischer Link — hier nicht löschbar",
"protectedStatus": "enthält die Rezept-Bibliothek — hier nicht löschbar",
"unknownStatus": "konnte nicht geprüft werden",
"deletedStatus": "gelöscht"
},
"folderResult": {
"unresolved": "Es konnte nicht ermittelt werden, welches Modell-Stammverzeichnis diesen Ordner enthält. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut."
},
"dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
@@ -1438,6 +1485,10 @@
"success": "{count} Update(s) für {type}s in diesem Ordner gefunden",
"none": "Alle {type}s in diesem Ordner sind aktuell",
"error": "Fehler beim Prüfen des Ordners auf {type}-Updates: {message}"
},
"scanFolder": "Diesen Ordner scannen",
"scanFolderResult": {
"missing": "Dieser Ordner existiert nicht mehr auf dem Datenträger. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut."
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "Für diese Version ist derzeit CivitAI Early Access erforderlich",
"paid": "Bezahlt",
"paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen",
"priceSaleTooltip": "Im Angebot, normalerweise {list} Buzz",
"priceBlueBuzzTooltip": "Kann auch mit Blue Buzz bezahlt werden",
"freeNow": "Jetzt kostenlos",
"freeNowTooltip": "Diese Version erfordert keine Zahlung mehr",
"ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar"
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf CivitAI verfügbar",
"earlyAccessTooltipUntil": "Früher Zugriff bis {date}, danach kostenlos zum Herunterladen"
},
"actions": {
"download": "Herunterladen",
@@ -2074,6 +2130,7 @@
"statSuccess": "Erfolgreich",
"statFailed": "Fehlgeschlagen",
"statSkipped": "Übersprungen",
"statOffline": "Laufwerk offline",
"statTotal": "Gesamt geprüft",
"statDuration": "Dauer",
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
"moveFailed": "Element konnte nicht verschoben werden: {message}",
"copiedToClipboard": "In die Zwischenablage kopiert",
"downloadStarted": "Download gestartet"
"downloadStarted": "Download gestartet",
"refreshCompleteScoped": "{scope} gescannt: {added} neu, {removed} entfernt",
"refreshKeptUnreachable": "{count} Modelle beibehalten: {paths} nicht erreichbar",
"scanRootUnreachable": "{scope} ist derzeit nicht erreichbar. Es wurde nichts geändert."
},
"agent": {
"llmNotConfigured": "KI-Anbieter nicht konfiguriert. Aktivieren Sie ihn unter Einstellungen → KI-Anbieter.",
+70 -10
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "Less than a minute remaining",
"minutes": "~{minutes} min remaining",
"hours": "~{hours} hr {minutes} min remaining"
}
},
"walkFiles": "{count} files"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "Checking for {type} updates...",
"success": "Found {count} update(s) for {type}s",
"none": "All {type}s are up to date",
"error": "Failed to check for {type} updates: {message}"
"error": "Failed to check for {type} updates: {message}",
"gateEvents": {
"becameFree": "{count} version(s) you do not have became free",
"newGate": "{count} version(s) are now paid"
}
},
"cleanupExampleImages": {
"label": "Clean up example image folders",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "Download Path Templates",
"priorityTags": "Priority Tags",
"versionScope": "Version Scope",
"priceTracking": "Buzz Download Prices",
"exampleImages": "Example Images",
"autoOrganize": "Auto-organize",
"metadata": "Metadata",
@@ -549,8 +555,8 @@
"cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)."
},
"folderSettings": {
"activeLibrary": "Active Library",
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.",
"activeLibrary": "Active Library (Deprecated)",
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page. Deprecated: multi-library support will be removed in an upcoming release. Multiple folder roots within a single library remain fully supported.",
"loadingLibraries": "Loading libraries...",
"noLibraries": "No libraries configured",
"defaultLoraRoot": "LoRA Root",
@@ -759,6 +765,13 @@
"label": "Hide Paid Updates",
"help": "When enabled, models with only paid updates will not show 'Update available' badge"
},
"priceTracking": {
"help": "CivitAI does not publish download prices in its public API, so they are read from the model page - one extra request per paid model, refreshed on the interval below. Prices are shown on versions you do not have yet.",
"enabled": "Show download prices for paid versions",
"enabledHelp": "Reads the download price from the model page so paid and early access versions show what they cost, and when early access ends",
"refreshHours": "Price refresh interval (hours)",
"refreshHoursHelp": "How long a stored price stays fresh before it is read again"
},
"licenseIcons": {
"useNewStyle": "Use updated license icons",
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
@@ -905,7 +918,10 @@
"refresh": {
"title": "Refresh model list",
"full": "Rebuild Cache",
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits.",
"scopeSection": "Scan one folder",
"rootOffline": "Offline",
"rootModels": "{count} models"
},
"fetch": {
"title": "Fetch metadata from CivitAI",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "This folder still contains models. 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.",
"missingTitle": "Folder not found",
"missingMessage": "This folder no longer exists on disk. Refresh the sidebar and try again.",
"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.",
"protectedTitle": "Folder is protected",
"protectedMessage": "This folder holds the recipe library and cannot be deleted from LoRA Manager.",
"busyTitle": "A deletion is still pending",
"checking": "Checking the folder contents...",
"confirm": "Delete folder"
"confirm": "Delete folder",
"confirmMulti": "Delete {count} folders",
"deleting": "Deleting...",
"keptNote": "Unchecked copies keep this folder in the sidebar."
},
"deleteFolderResult": {
"success": "Folder \"{name}\" deleted",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "This folder still contains {count} model file(s). Refresh the sidebar and try again.",
"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",
"noRoot": "No model root is configured"
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again.",
"symlink": "This folder is a symbolic link; it cannot be deleted from the sidebar.",
"successMulti": "Folder \"{name}\" deleted from {count} model roots",
"partial": "Deleted {count} of {total} copies — {failed} could not be deleted. Refresh the sidebar and retry."
},
"renameFolder": "Rename folder",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "A folder with that name already exists here",
"busy": "A deletion is still pending inside this folder. Wait for the undo window to expire.",
"unsupported": "Folder renaming is not supported on this page",
"noRoot": "No model root is configured"
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again.",
"symlink": "This folder is a symbolic link; it cannot be renamed from the sidebar."
},
"folderRoot": {
"chooseTitle": "This folder exists in more than one model root",
"symlinkOption": "symbolic link",
"rootsHint": "Select the model roots to delete this folder from:",
"checkingStatus": "checking...",
"okStatus": "no models",
"notEmptyStatus": "contains {count} model file(s) — delete or move them first",
"notEmptyExcludedStatus": "contains {count} model file(s), {excluded} of them excluded from the library",
"busyStatus": "a deletion is still pending",
"missingStatus": "no longer exists on disk",
"symlinkStatus": "symbolic link — cannot be deleted here",
"protectedStatus": "holds the recipe library — cannot be deleted here",
"unknownStatus": "could not be checked",
"deletedStatus": "deleted"
},
"folderResult": {
"unresolved": "Could not determine which model root holds this folder. Refresh the sidebar and try again."
},
"dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.",
@@ -1438,6 +1485,10 @@
"success": "Found {count} update(s) for {type}s in this folder",
"none": "All {type}s in this folder are up to date",
"error": "Failed to check folder for {type} updates: {message}"
},
"scanFolder": "Scan this folder",
"scanFolderResult": {
"missing": "This folder no longer exists on disk. Refresh the sidebar and try again."
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "This version currently requires CivitAI early access",
"paid": "Paid",
"paidTooltip": "This version requires payment to download",
"priceSaleTooltip": "On sale, normally {list} Buzz",
"priceBlueBuzzTooltip": "Can also be paid with Blue Buzz",
"freeNow": "Free Now",
"freeNowTooltip": "This version no longer requires payment",
"ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
"onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI"
"onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI",
"earlyAccessTooltipUntil": "Early access until {date}, free to download after that"
},
"actions": {
"download": "Download",
@@ -2074,6 +2130,7 @@
"statSuccess": "Success",
"statFailed": "Failed",
"statSkipped": "Skipped",
"statOffline": "Drive Offline",
"statTotal": "Total Scanned",
"statDuration": "Duration",
"successMessage": "All {count} {type}s updated successfully!",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
"moveFailed": "Failed to move item: {message}",
"copiedToClipboard": "Copied to clipboard",
"downloadStarted": "Download started"
"downloadStarted": "Download started",
"refreshCompleteScoped": "Scanned {scope}: {added} new, {removed} removed",
"refreshKeptUnreachable": "{count} models kept: {paths} not reachable",
"scanRootUnreachable": "{scope} is not reachable right now. Nothing was changed."
},
"agent": {
"llmNotConfigured": "AI provider not configured. Enable it in Settings → AI Provider.",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "Queda menos de un minuto",
"minutes": "Quedan ~{minutes} min",
"hours": "Quedan ~{hours} h {minutes} min"
}
},
"walkFiles": "{count} archivos"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "Buscando actualizaciones de {type}...",
"success": "Se encontraron {count} actualización(es) para {type}",
"none": "Todos los {type} están actualizados",
"error": "Error al buscar actualizaciones de {type}: {message}"
"error": "Error al buscar actualizaciones de {type}: {message}",
"gateEvents": {
"becameFree": "{count} versión(es) que no tienes se han vuelto gratuitas",
"newGate": "{count} versión(es) ahora son de pago"
}
},
"cleanupExampleImages": {
"label": "Limpiar carpetas de imágenes de ejemplo",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "Plantillas de rutas de descarga",
"priorityTags": "Etiquetas prioritarias",
"versionScope": "Indicadores de actualización",
"priceTracking": "Precios de descarga en Buzz",
"exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática",
"metadata": "Metadatos",
@@ -759,6 +765,13 @@
"label": "Ocultar actualizaciones de pago",
"help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'"
},
"priceTracking": {
"help": "La API pública de CivitAI no publica los precios de descarga, así que se leen de la página del modelo: una petición adicional por modelo de pago, actualizada según el intervalo de abajo. Los precios se muestran solo en las versiones que aún no tienes.",
"enabled": "Mostrar los precios de descarga de las versiones de pago",
"enabledHelp": "Lee el precio de descarga de la página del modelo para mostrar cuánto cuestan las versiones de pago y de acceso temprano, y cuándo termina el acceso temprano",
"refreshHours": "Intervalo de actualización de precios (horas)",
"refreshHoursHelp": "Cuánto tiempo sigue vigente un precio guardado antes de volver a leerse"
},
"licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados",
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
@@ -905,7 +918,10 @@
"refresh": {
"title": "Actualizar lista de modelos",
"full": "Reconstruir caché",
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales.",
"scopeSection": "Escanear solo una carpeta",
"rootOffline": "Sin conexión",
"rootModels": "{count} modelos"
},
"fetch": {
"title": "Obtener metadatos de CivitAI",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "Esta carpeta aún contiene modelos. 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.",
"missingTitle": "Carpeta no encontrada",
"missingMessage": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
"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.",
"protectedTitle": "Carpeta protegida",
"protectedMessage": "Esta carpeta contiene la biblioteca de recetas y no se puede eliminar desde LoRA Manager.",
"busyTitle": "Todavía hay una eliminación pendiente",
"checking": "Comprobando el contenido de la carpeta...",
"confirm": "Eliminar carpeta"
"confirm": "Eliminar carpeta",
"confirmMulti": "Eliminar {count} carpetas",
"deleting": "Eliminando...",
"keptNote": "Las copias no marcadas mantienen esta carpeta en la barra lateral."
},
"deleteFolderResult": {
"success": "Carpeta \"{name}\" eliminada",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "Esta carpeta aún contiene {count} archivo(s) de modelo. Actualiza la barra lateral e inténtalo de nuevo.",
"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",
"noRoot": "No hay ninguna raíz de modelo configurada"
"missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
"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",
"partial": "Se eliminaron {count} de {total} copias — {failed} no se pudieron eliminar. Actualiza la barra lateral e inténtalo de nuevo."
},
"renameFolder": "Cambiar nombre de la carpeta",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "Ya existe una carpeta con ese nombre aquí",
"busy": "Todavía hay una eliminación pendiente dentro de esta carpeta. Espera a que caduque la ventana de deshacer.",
"unsupported": "El cambio de nombre de carpetas no es compatible con esta página",
"noRoot": "No hay ninguna raíz de modelo configurada"
"missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo.",
"symlink": "Esta carpeta es un enlace simbólico; no se puede cambiar el nombre desde la barra lateral."
},
"folderRoot": {
"chooseTitle": "Esta carpeta existe en más de una raíz de modelo",
"symlinkOption": "enlace simbólico",
"rootsHint": "Selecciona las raíces de modelo desde las que eliminar esta carpeta:",
"checkingStatus": "comprobando...",
"okStatus": "sin modelos",
"notEmptyStatus": "contiene {count} archivo(s) de modelo — elimínalos o muévelos primero",
"notEmptyExcludedStatus": "contiene {count} archivo(s) de modelo, de los cuales {excluded} están excluidos de la biblioteca",
"busyStatus": "todavía hay una eliminación pendiente",
"missingStatus": "ya no existe en el disco",
"symlinkStatus": "enlace simbólico — no se puede eliminar aquí",
"protectedStatus": "contiene la biblioteca de recetas — no se puede eliminar aquí",
"unknownStatus": "no se pudo comprobar",
"deletedStatus": "eliminada"
},
"folderResult": {
"unresolved": "No se pudo determinar qué raíz de modelo contiene esta carpeta. Actualiza la barra lateral e inténtalo de nuevo."
},
"dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
@@ -1438,6 +1485,10 @@
"success": "Se encontraron {count} actualización(es) para {type}s en esta carpeta",
"none": "Todos los {type}s en esta carpeta están actualizados",
"error": "Error al buscar actualizaciones de {type} en la carpeta: {message}"
},
"scanFolder": "Escanear esta carpeta",
"scanFolderResult": {
"missing": "Esta carpeta ya no existe en el disco. Actualiza la barra lateral e inténtalo de nuevo."
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de CivitAI",
"paid": "De pago",
"paidTooltip": "Esta versión requiere pago para descargarse",
"priceSaleTooltip": "En oferta, normalmente {list} Buzz",
"priceBlueBuzzTooltip": "También se puede pagar con Blue Buzz",
"freeNow": "Ahora gratis",
"freeNowTooltip": "Esta versión ya no requiere pago",
"ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI"
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de CivitAI",
"earlyAccessTooltipUntil": "Acceso temprano hasta el {date}, descarga gratuita después"
},
"actions": {
"download": "Descargar",
@@ -2074,6 +2130,7 @@
"statSuccess": "Éxito",
"statFailed": "Fallido",
"statSkipped": "Omitido",
"statOffline": "Unidad sin conexión",
"statTotal": "Total escaneado",
"statDuration": "Duración",
"successMessage": "¡Todos los {count} {type}s actualizados correctamente!",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
"moveFailed": "No se pudo mover el elemento: {message}",
"copiedToClipboard": "Copiado al portapapeles",
"downloadStarted": "Descarga iniciada"
"downloadStarted": "Descarga iniciada",
"refreshCompleteScoped": "{scope} escaneado: {added} nuevo(s), {removed} eliminado(s)",
"refreshKeptUnreachable": "{count} modelos conservados: {paths} no accesible(s)",
"scanRootUnreachable": "{scope} no es accesible ahora mismo. No se ha cambiado nada."
},
"agent": {
"llmNotConfigured": "Proveedor de IA no configurado. Actívelo en Configuración → Proveedor de IA.",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "Moins d’une minute restante",
"minutes": "~{minutes} min restantes",
"hours": "~{hours} h {minutes} min restantes"
}
},
"walkFiles": "{count} fichiers"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "Recherche de mises à jour pour {type}...",
"success": "{count} mise(s) à jour trouvée(s) pour {type}",
"none": "Tous les {type} sont à jour",
"error": "Échec de la vérification des mises à jour pour {type} : {message}"
"error": "Échec de la vérification des mises à jour pour {type} : {message}",
"gateEvents": {
"becameFree": "{count} version(s) que vous ne possédez pas sont devenues gratuites",
"newGate": "{count} version(s) sont désormais payantes"
}
},
"cleanupExampleImages": {
"label": "Supprimer les dossiers d'exemples orphelins",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"priorityTags": "Tags prioritaires",
"versionScope": "Indicateurs de mise à jour",
"priceTracking": "Prix de téléchargement en Buzz",
"exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique",
"metadata": "Métadonnées",
@@ -759,6 +765,13 @@
"label": "Masquer les mises à jour payantes",
"help": "Lorsque cette option est activée, les modèles n'ayant que des mises à jour payantes n'affichent pas le badge « Mise à jour disponible »"
},
"priceTracking": {
"help": "L'API publique de CivitAI ne publie pas les prix de téléchargement : ils sont donc lus sur la page du modèle - une requête supplémentaire par modèle payant, actualisée selon l'intervalle ci-dessous. Les prix sont affichés sur les versions que vous ne possédez pas encore.",
"enabled": "Afficher les prix de téléchargement des versions payantes",
"enabledHelp": "Lit le prix de téléchargement sur la page du modèle pour indiquer le coût des versions payantes et en accès anticipé, ainsi que la fin de l'accès anticipé",
"refreshHours": "Intervalle d'actualisation des prix (heures)",
"refreshHoursHelp": "Durée pendant laquelle un prix enregistré reste valide avant d'être relu"
},
"licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour",
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
@@ -905,7 +918,10 @@
"refresh": {
"title": "Actualiser la liste des modèles",
"full": "Reconstruire le cache",
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers de métadonnées — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers de métadonnées — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles.",
"scopeSection": "Analyser un seul dossier",
"rootOffline": "Hors ligne",
"rootModels": "{count} modèles"
},
"fetch": {
"title": "Récupérer les métadonnées depuis CivitAI",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "Ce dossier contient encore des modèles. 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.",
"missingTitle": "Dossier introuvable",
"missingMessage": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
"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.",
"protectedTitle": "Dossier protégé",
"protectedMessage": "Ce dossier contient la bibliothèque de Recipes et ne peut pas être supprimé depuis LoRA Manager.",
"busyTitle": "Une suppression est encore en attente",
"checking": "Vérification du contenu du dossier...",
"confirm": "Supprimer le dossier"
"confirm": "Supprimer le dossier",
"confirmMulti": "Supprimer {count} dossiers",
"deleting": "Suppression...",
"keptNote": "Les copies non cochées maintiennent ce dossier dans la barre latérale."
},
"deleteFolderResult": {
"success": "Dossier \"{name}\" supprimé",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "Ce dossier contient encore {count} fichier(s) de modèle. Actualisez la barre latérale et réessayez.",
"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",
"noRoot": "Aucune racine de modèle n’est configurée"
"missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
"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",
"partial": "{count} copie(s) sur {total} supprimée(s) — {failed} en échec. Actualisez la barre latérale et réessayez."
},
"renameFolder": "Renommer le dossier",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "Un dossier portant ce nom existe déjà ici",
"busy": "Une suppression est encore en attente dans ce dossier. Attendez la fin de la fenêtre d’annulation.",
"unsupported": "Le renommage de dossiers n’est pas pris en charge sur cette page",
"noRoot": "Aucune racine de modèle n’est configurée"
"missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez.",
"symlink": "Ce dossier est un lien symbolique ; il ne peut pas être renommé depuis la barre latérale."
},
"folderRoot": {
"chooseTitle": "Ce dossier existe dans plusieurs racines de modèle",
"symlinkOption": "lien symbolique",
"rootsHint": "Sélectionnez les racines de modèle d’où supprimer ce dossier :",
"checkingStatus": "vérification...",
"okStatus": "aucun modèle",
"notEmptyStatus": "contient {count} fichier(s) de modèle — supprimez-les ou déplacez-les d’abord",
"notEmptyExcludedStatus": "contient {count} fichier(s) de modèle, dont {excluded} exclus de la bibliothèque",
"busyStatus": "une suppression est encore en attente",
"missingStatus": "n’existe plus sur le disque",
"symlinkStatus": "lien symbolique — impossible à supprimer ici",
"protectedStatus": "contient la bibliothèque de Recipes — impossible à supprimer ici",
"unknownStatus": "vérification impossible",
"deletedStatus": "supprimé"
},
"folderResult": {
"unresolved": "Impossible de déterminer quelle racine de modèle contient ce dossier. Actualisez la barre latérale et réessayez."
},
"dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
@@ -1438,6 +1485,10 @@
"success": "{count} mise(s) à jour trouvée(s) pour les {type}s dans ce dossier",
"none": "Tous les {type}s dans ce dossier sont à jour",
"error": "Échec de la vérification des mises à jour {type} dans ce dossier : {message}"
},
"scanFolder": "Analyser ce dossier",
"scanFolderResult": {
"missing": "Ce dossier n’existe plus sur le disque. Actualisez la barre latérale et réessayez."
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé CivitAI",
"paid": "Payant",
"paidTooltip": "Cette version nécessite un paiement pour être téléchargée",
"priceSaleTooltip": "En promotion, normalement {list} Buzz",
"priceBlueBuzzTooltip": "Peut aussi être payé avec des Blue Buzz",
"freeNow": "Gratuit maintenant",
"freeNowTooltip": "Cette version ne nécessite plus de paiement",
"ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI"
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site CivitAI",
"earlyAccessTooltipUntil": "Accès anticipé jusqu'au {date}, téléchargement gratuit ensuite"
},
"actions": {
"download": "Télécharger",
@@ -2074,6 +2130,7 @@
"statSuccess": "Réussi",
"statFailed": "Échoué",
"statSkipped": "Ignoré",
"statOffline": "Lecteur hors ligne",
"statTotal": "Total scanné",
"statDuration": "Durée",
"successMessage": "Tous les {count} {type}s mis à jour avec succès !",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
"moveFailed": "Échec du déplacement de l'élément : {message}",
"copiedToClipboard": "Copié dans le presse-papiers",
"downloadStarted": "Téléchargement démarré"
"downloadStarted": "Téléchargement démarré",
"refreshCompleteScoped": "{scope} analysé : {added} nouveau(x), {removed} supprimé(s)",
"refreshKeptUnreachable": "{count} modèles conservés : {paths} inaccessible(s)",
"scanRootUnreachable": "{scope} est actuellement inaccessible. Aucune modification n’a été apportée."
},
"agent": {
"llmNotConfigured": "Fournisseur d'IA non configuré. Activez-le dans Paramètres → Fournisseur d'IA.",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "נותרה פחות מדקה",
"minutes": "נותרו ~{minutes} דקות",
"hours": "נותרו ~{hours} שעות ו-{minutes} דקות"
}
},
"walkFiles": "{count} קבצים"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "בודק עדכונים עבור {type}...",
"success": "נמצאו {count} עדכונים עבור {type}",
"none": "כל ה-{type} מעודכנים",
"error": "נכשל בבדיקת העדכונים עבור {type}: {message}"
"error": "נכשל בבדיקת העדכונים עבור {type}: {message}",
"gateEvents": {
"becameFree": "{count} גרסאות שאין לך הפכו לחינמיות",
"newGate": "{count} גרסאות דורשות כעת תשלום"
}
},
"cleanupExampleImages": {
"label": "נקה תיקיות תמונות דוגמה",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות",
"versionScope": "תגי עדכון",
"priceTracking": "מחירי הורדה ב-Buzz",
"exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי",
"metadata": "מטא-נתונים",
@@ -759,6 +765,13 @@
"label": "הסתר עדכונים בתשלום",
"help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'"
},
"priceTracking": {
"help": "ה-API הציבורי של CivitAI אינו מפרסם מחירי הורדה, ולכן הם נקראים מדף המודל - בקשה נוספת אחת לכל מודל בתשלום, ברענון לפי המרווח שלמטה. המחירים מוצגים רק בגרסאות שאין לך עדיין.",
"enabled": "הצג מחירי הורדה של גרסאות בתשלום",
"enabledHelp": "קורא את מחיר ההורדה מדף המודל כדי להציג כמה עולות גרסאות בתשלום וגרסאות בגישה מוקדמת, ומתי מסתיימת הגישה המוקדמת",
"refreshHours": "מרווח רענון המחירים (שעות)",
"refreshHoursHelp": "כמה זמן מחיר שמור נחשב מעודכן לפני קריאה חוזרת"
},
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
@@ -905,7 +918,10 @@
"refresh": {
"title": "רענן רשימת מודלים",
"full": "בניית מטמון מחדש",
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-נתונים – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-נתונים – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות.",
"scopeSection": "סרוק תיקייה אחת בלבד",
"rootOffline": "לא זמין",
"rootModels": "{count} מודלים"
},
"fetch": {
"title": "אחזר מטא-נתונים מ-CivitAI",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
"missingTitle": "התיקייה לא נמצאה",
"missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
"symlinkTitle": "התיקייה היא קישור סמלי",
"symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.",
"protectedTitle": "תיקייה מוגנת",
"protectedMessage": "תיקייה זו מכילה את ספריית המתכונים ולא ניתן למחוק אותה מתוך LoRA Manager.",
"busyTitle": "מחיקה עדיין ממתינה",
"checking": "בודק את תוכן התיקייה...",
"confirm": "מחק תיקייה"
"confirm": "מחק תיקייה",
"confirmMulti": "מחק {count} תיקיות",
"deleting": "מוחק...",
"keptNote": "עותקים שלא סומנו ישאירו את התיקייה הזו בסרגל הצד."
},
"deleteFolderResult": {
"success": "התיקייה \"{name}\" נמחקה",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
"symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.",
"successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים",
"partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב."
},
"renameFolder": "שנה שם תיקייה",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "תיקייה בשם זה כבר קיימת כאן",
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
"unsupported": "שינוי שם תיקיות אינו נתמך בדף זה",
"noRoot": "לא הוגדר שורש מודלים"
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
"symlink": "תיקייה זו היא קישור סמלי; לא ניתן לשנות את שמה מסרגל הצד."
},
"folderRoot": {
"chooseTitle": "תיקייה זו קיימת ביותר משורש מודלים אחד",
"symlinkOption": "קישור סמלי",
"rootsHint": "בחר את שורשי המודלים שמהם למחוק את התיקייה הזו:",
"checkingStatus": "בודק...",
"okStatus": "אין מודלים",
"notEmptyStatus": "מכיל {count} קובצי מודלים — מחק או העבר אותם תחילה",
"notEmptyExcludedStatus": "מכיל {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה",
"busyStatus": "מחיקה עדיין ממתינה",
"missingStatus": "אינו קיים עוד בדיסק",
"symlinkStatus": "קישור סמלי — לא ניתן למחוק כאן",
"protectedStatus": "מכילה את ספריית המתכונים — לא ניתן למחוק כאן",
"unknownStatus": "לא ניתן היה לבדוק",
"deletedStatus": "נמחקה"
},
"folderResult": {
"unresolved": "לא ניתן לקבוע באיזה שורש מודלים נמצאת התיקייה הזו. רענן את סרגל הצד ונסה שוב."
},
"dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
@@ -1438,6 +1485,10 @@
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
},
"scanFolder": "סרוק תיקייה זו",
"scanFolderResult": {
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב."
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של CivitAI",
"paid": "בתשלום",
"paidTooltip": "גרסה זו דורשת תשלום כדי להוריד",
"priceSaleTooltip": "במבצע, בדרך כלל {list} Buzz",
"priceBlueBuzzTooltip": "ניתן לשלם גם ב-Blue Buzz",
"freeNow": "חינם כעת",
"freeNowTooltip": "גרסה זו אינה דורשת עוד תשלום",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI"
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר CivitAI",
"earlyAccessTooltipUntil": "גישה מוקדמת עד {date}, לאחר מכן ההורדה חינם"
},
"actions": {
"download": "הורדה",
@@ -2074,6 +2130,7 @@
"statSuccess": "הצלחה",
"statFailed": "נכשל",
"statSkipped": "דולג",
"statOffline": "כונן לא זמין",
"statTotal": "סה\"כ נסרק",
"statDuration": "משך",
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
"moveFailed": "לא ניתן היה להעביר את הפריט: {message}",
"copiedToClipboard": "הועתק ללוח",
"downloadStarted": "ההורדה החלה"
"downloadStarted": "ההורדה החלה",
"refreshCompleteScoped": "{scope} נסרק: {added} חדשים, {removed} הוסרו",
"refreshKeptUnreachable": "נשמרו {count} מודלים: {paths} אינם זמינים",
"scanRootUnreachable": "{scope} אינו זמין כעת. לא בוצעו שינויים."
},
"agent": {
"llmNotConfigured": "ספק AI לא הוגדר. הפעל אותו בהגדרות → ספק AI.",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "残り1分未満",
"minutes": "残り約 {minutes} 分",
"hours": "残り約 {hours} 時間 {minutes} 分"
}
},
"walkFiles": "{count} 件のファイル"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "{type} のアップデートを確認中...",
"success": "{type} のアップデートが {count} 件見つかりました",
"none": "すべての {type} は最新です",
"error": "{type} のアップデート確認に失敗しました: {message}"
"error": "{type} のアップデート確認に失敗しました: {message}",
"gateEvents": {
"becameFree": "未ダウンロードの {count} バージョンが無料になりました",
"newGate": "{count} バージョンが有料になりました"
}
},
"cleanupExampleImages": {
"label": "例画像フォルダをクリーンアップ",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ",
"versionScope": "アップデートフラグ",
"priceTracking": "Buzz ダウンロード価格",
"exampleImages": "例画像",
"autoOrganize": "自動整理",
"metadata": "メタデータ",
@@ -759,6 +765,13 @@
"label": "有料更新を非表示",
"help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
},
"priceTracking": {
"help": "CivitAI の公開 API はダウンロード価格を提供していないため、モデルページから読み取ります。有料モデルごとに 1 回の追加リクエストが発生し、以下の間隔で更新されます。価格は未ダウンロードのバージョンにのみ表示されます。",
"enabled": "有料バージョンのダウンロード価格を表示",
"enabledHelp": "モデルページからダウンロード価格を読み取り、有料バージョンと早期アクセスバージョンの費用と早期アクセスの終了時期を表示します",
"refreshHours": "価格の更新間隔(時間)",
"refreshHoursHelp": "保存された価格が再読み取りまで有効な期間"
},
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
@@ -905,7 +918,10 @@
"refresh": {
"title": "モデルリストを更新",
"full": "キャッシュを再構築",
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。",
"scopeSection": "フォルダを 1 つだけスキャン",
"rootOffline": "オフライン",
"rootModels": "{count} 個のモデル"
},
"fetch": {
"title": "CivitAIからメタデータを取得",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
"missingTitle": "フォルダが見つかりません",
"missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"symlinkTitle": "フォルダがシンボリックリンクです",
"symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。",
"protectedTitle": "フォルダは保護されています",
"protectedMessage": "このフォルダにはレシピライブラリが保存されているため、LoRA Manager から削除できません。",
"busyTitle": "保留中の削除があります",
"checking": "フォルダの内容を確認しています...",
"confirm": "フォルダを削除"
"confirm": "フォルダを削除",
"confirmMulti": "{count} 個のフォルダを削除",
"deleting": "削除しています...",
"keptNote": "チェックを外したコピーがあると、このフォルダはサイドバーに残ります。"
},
"deleteFolderResult": {
"success": "フォルダ \"{name}\" を削除しました",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダを削除できません",
"noRoot": "モデルルートが設定されていません"
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。",
"successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました",
"partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
},
"renameFolder": "フォルダ名を変更",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "同じ名前のフォルダが既に存在します",
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
"unsupported": "このページではフォルダ名を変更できません",
"noRoot": "モデルルートが設定されていません"
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
"symlink": "このフォルダはシンボリックリンクです。サイドバーからはフォルダ名を変更できません。"
},
"folderRoot": {
"chooseTitle": "このフォルダは複数のモデルルートに存在します",
"symlinkOption": "シンボリックリンク",
"rootsHint": "このフォルダを削除するモデルルートを選択してください:",
"checkingStatus": "確認しています...",
"okStatus": "モデルなし",
"notEmptyStatus": "モデルファイルが {count} 個あります —— 先に削除するか移動してください",
"notEmptyExcludedStatus": "モデルファイルが {count} 個あり、そのうち {excluded} 個はライブラリから除外されています",
"busyStatus": "保留中の削除があります",
"missingStatus": "ディスク上に存在しません",
"symlinkStatus": "シンボリックリンク —— ここからは削除できません",
"protectedStatus": "レシピライブラリを保存 —— ここからは削除できません",
"unknownStatus": "確認できませんでした",
"deletedStatus": "削除しました"
},
"folderResult": {
"unresolved": "このフォルダがどのモデルルートにあるかを特定できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
},
"dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。",
@@ -1438,6 +1485,10 @@
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
"none": "このフォルダのすべての{type}sは最新です",
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
},
"scanFolder": "このフォルダをスキャン",
"scanFolderResult": {
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。"
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "このバージョンは現在 CivitAI の早期アクセスが必要です",
"paid": "有料",
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
"priceSaleTooltip": "セール中、通常 {list} Buzz",
"priceBlueBuzzTooltip": "Blue Buzz でも支払えます",
"freeNow": "無料化",
"freeNowTooltip": "このバージョンは支払いが不要になりました",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません"
"onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません",
"earlyAccessTooltipUntil": "{date} まで早期アクセス、以降は無料でダウンロードできます"
},
"actions": {
"download": "ダウンロード",
@@ -2074,6 +2130,7 @@
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "スキップ",
"statOffline": "ドライブ オフライン",
"statTotal": "スキャン合計",
"statDuration": "所要時間",
"successMessage": "すべての{count}件の{type}を正常に更新しました",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "アイテムを移動できませんでした: {message}",
"copiedToClipboard": "クリップボードにコピーしました",
"downloadStarted": "ダウンロードを開始しました"
"downloadStarted": "ダウンロードを開始しました",
"refreshCompleteScoped": "{scope} をスキャンしました:新規 {added} 件、削除 {removed} 件",
"refreshKeptUnreachable": "{count} 個のモデルを保持しました:{paths} にアクセスできません",
"scanRootUnreachable": "{scope} に現在アクセスできません。変更は行われていません。"
},
"agent": {
"llmNotConfigured": "AIプロバイダーが設定されていません。設定 → AIプロバイダーで有効にしてください。",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "남은 시간 1분 미만",
"minutes": "약 {minutes}분 남음",
"hours": "약 {hours}시간 {minutes}분 남음"
}
},
"walkFiles": "파일 {count}개"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "{type} 업데이트를 확인 중...",
"success": "{type} 업데이트 {count}개를 찾았습니다",
"none": "모든 {type}가 최신 상태입니다",
"error": "{type} 업데이트 확인 실패: {message}"
"error": "{type} 업데이트 확인 실패: {message}",
"gateEvents": {
"becameFree": "보유하지 않은 버전 {count}개가 무료가 되었습니다",
"newGate": "버전 {count}개가 유료로 전환되었습니다"
}
},
"cleanupExampleImages": {
"label": "예시 이미지 폴더 정리",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그",
"versionScope": "업데이트 표시",
"priceTracking": "Buzz 다운로드 가격",
"exampleImages": "예시 이미지",
"autoOrganize": "자동 정리",
"metadata": "메타데이터",
@@ -759,6 +765,13 @@
"label": "유료 업데이트 숨기기",
"help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
},
"priceTracking": {
"help": "CivitAI 공개 API는 다운로드 가격을 제공하지 않으므로 모델 페이지에서 읽어옵니다. 유료 모델마다 요청이 한 번씩 추가되며, 아래 간격에 따라 갱신됩니다. 가격은 아직 보유하지 않은 버전에만 표시됩니다.",
"enabled": "유료 버전의 다운로드 가격 표시",
"enabledHelp": "모델 페이지에서 다운로드 가격을 읽어 유료 및 얼리 액세스 버전의 비용과 얼리 액세스 종료 시점을 표시합니다",
"refreshHours": "가격 갱신 간격(시간)",
"refreshHoursHelp": "저장된 가격을 다시 읽기 전까지 유지하는 기간"
},
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
@@ -905,7 +918,10 @@
"refresh": {
"title": "모델 목록 새로고침",
"full": "캐시 재구성",
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요.",
"scopeSection": "폴더 하나만 스캔",
"rootOffline": "오프라인",
"rootModels": "모델 {count}개"
},
"fetch": {
"title": "CivitAI에서 메타데이터 가져오기",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
"missingTitle": "폴더를 찾을 수 없습니다",
"missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"symlinkTitle": "폴더가 심볼릭 링크입니다",
"symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.",
"protectedTitle": "보호된 폴더",
"protectedMessage": "이 폴더에는 레시피 라이브러리가 저장되어 있으므로 LoRA Manager에서 삭제할 수 없습니다.",
"busyTitle": "대기 중인 삭제 작업이 있습니다",
"checking": "폴더 내용을 확인하는 중...",
"confirm": "폴더 삭제"
"confirm": "폴더 삭제",
"confirmMulti": "폴더 {count}개 삭제",
"deleting": "삭제하는 중...",
"keptNote": "선택하지 않은 복사본이 있으면 이 폴더가 사이드바에 계속 표시됩니다."
},
"deleteFolderResult": {
"success": "\"{name}\" 폴더를 삭제했습니다",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.",
"successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다",
"partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요."
},
"renameFolder": "폴더 이름 바꾸기",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "같은 이름의 폴더가 이미 있습니다",
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
"unsupported": "이 페이지에서는 폴더 이름을 바꿀 수 없습니다",
"noRoot": "모델 루트가 설정되지 않았습니다"
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
"symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 이름을 바꿀 수 없습니다."
},
"folderRoot": {
"chooseTitle": "이 폴더는 여러 모델 루트에 존재합니다",
"symlinkOption": "심볼릭 링크",
"rootsHint": "이 폴더를 삭제할 모델 루트를 선택하세요:",
"checkingStatus": "확인하는 중...",
"okStatus": "모델 없음",
"notEmptyStatus": "모델 파일이 {count}개 있습니다 —— 먼저 삭제하거나 이동하세요",
"notEmptyExcludedStatus": "모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다",
"busyStatus": "대기 중인 삭제 작업이 있습니다",
"missingStatus": "디스크에 존재하지 않습니다",
"symlinkStatus": "심볼릭 링크 —— 여기에서는 삭제할 수 없습니다",
"protectedStatus": "레시피 라이브러리 보관 중 —— 여기에서는 삭제할 수 없습니다",
"unknownStatus": "확인할 수 없습니다",
"deletedStatus": "삭제됨"
},
"folderResult": {
"unresolved": "이 폴더가 어느 모델 루트에 있는지 확인할 수 없습니다. 사이드바를 새로 고친 후 다시 시도하세요."
},
"dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
@@ -1438,6 +1485,10 @@
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
},
"scanFolder": "이 폴더 스캔",
"scanFolderResult": {
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요."
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "이 버전은 현재 CivitAI 얼리 액세스가 필요합니다",
"paid": "유료",
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
"priceSaleTooltip": "할인 중, 원래 {list} Buzz",
"priceBlueBuzzTooltip": "Blue Buzz로도 결제할 수 있습니다",
"freeNow": "지금 무료",
"freeNowTooltip": "이 버전은 더 이상 결제가 필요하지 않습니다",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
"onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
"onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"earlyAccessTooltipUntil": "{date}까지 얼리 액세스, 이후 무료로 다운로드할 수 있습니다"
},
"actions": {
"download": "다운로드",
@@ -2074,6 +2130,7 @@
"statSuccess": "성공",
"statFailed": "실패",
"statSkipped": "건너뜀",
"statOffline": "드라이브 오프라인",
"statTotal": "총 스캔",
"statDuration": "소요 시간",
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
"moveFailed": "항목을 이동하지 못했습니다: {message}",
"copiedToClipboard": "클립보드에 복사됨",
"downloadStarted": "다운로드 시작됨"
"downloadStarted": "다운로드 시작됨",
"refreshCompleteScoped": "{scope} 스캔 완료: 새 모델 {added}개, 제거 {removed}개",
"refreshKeptUnreachable": "모델 {count}개 유지됨: {paths}에 접근할 수 없음",
"scanRootUnreachable": "{scope}에 현재 접근할 수 없습니다. 변경된 내용은 없습니다."
},
"agent": {
"llmNotConfigured": "AI 제공자가 설정되지 않았습니다. 설정 → AI 제공자에서 활성화하세요.",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "Осталось меньше минуты",
"minutes": "Осталось ~{minutes} мин",
"hours": "Осталось ~{hours} ч {minutes} мин"
}
},
"walkFiles": "{count} файл(ов)"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "Проверка обновлений для {type}...",
"success": "Найдено {count} обновлений для {type}",
"none": "Все {type} актуальны",
"error": "Не удалось проверить обновления для {type}: {message}"
"error": "Не удалось проверить обновления для {type}: {message}",
"gateEvents": {
"becameFree": "{count} версий, которых у вас нет, стали бесплатными",
"newGate": "{count} версий стали платными"
}
},
"cleanupExampleImages": {
"label": "Очистить папки с примерами изображений",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги",
"versionScope": "Метки обновлений",
"priceTracking": "Цены за скачивание в Buzz",
"exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация",
"metadata": "Метаданные",
@@ -759,6 +765,13 @@
"label": "Скрывать платные обновления",
"help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»"
},
"priceTracking": {
"help": "Публичный API CivitAI не публикует цены за скачивание, поэтому они читаются со страницы модели — один дополнительный запрос на каждую платную модель, обновление с интервалом ниже. Цены показываются только для версий, которых у вас ещё нет.",
"enabled": "Показывать цены за скачивание платных версий",
"enabledHelp": "Читает цену за скачивание со страницы модели, чтобы платные версии и версии с ранним доступом показывали стоимость и дату окончания раннего доступа",
"refreshHours": "Интервал обновления цен (часы)",
"refreshHoursHelp": "Сколько хранится цена, прежде чем её прочитают снова"
},
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
@@ -905,7 +918,10 @@
"refresh": {
"title": "Обновить список моделей",
"full": "Перестроить кэш",
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок.",
"scopeSection": "Сканировать только одну папку",
"rootOffline": "Недоступен",
"rootModels": "{count} модел(ей)"
},
"fetch": {
"title": "Получить метаданные с CivitAI",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
"missingTitle": "Папка не найдена",
"missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
"symlinkTitle": "Папка является символической ссылкой",
"symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.",
"protectedTitle": "Папка защищена",
"protectedMessage": "В этой папке хранится библиотека рецептов, её нельзя удалить из LoRA Manager.",
"busyTitle": "Удаление всё ещё отложено",
"checking": "Проверка содержимого папки...",
"confirm": "Удалить папку"
"confirm": "Удалить папку",
"confirmMulti": "Удалить папки ({count})",
"deleting": "Удаление...",
"keptNote": "Неотмеченные копии оставят эту папку в боковой панели."
},
"deleteFolderResult": {
"success": "Папка \"{name}\" удалена",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Удаление папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
"symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.",
"successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей",
"partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку."
},
"renameFolder": "Переименовать папку",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "Папка с таким именем уже существует здесь",
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
"unsupported": "Переименование папок не поддерживается на этой странице",
"noRoot": "Корневая папка моделей не настроена"
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
"symlink": "Эта папка является символической ссылкой; её нельзя переименовать из боковой панели."
},
"folderRoot": {
"chooseTitle": "Эта папка существует в нескольких корневых папках моделей",
"symlinkOption": "символическая ссылка",
"rootsHint": "Выберите корневые папки моделей, из которых нужно удалить эту папку:",
"checkingStatus": "проверка...",
"okStatus": "моделей нет",
"notEmptyStatus": "содержит {count} файл(ов) модели — сначала удалите или переместите их",
"notEmptyExcludedStatus": "содержит {count} файл(ов) модели, из них {excluded} исключены из библиотеки",
"busyStatus": "удаление всё ещё отложено",
"missingStatus": "больше не существует на диске",
"symlinkStatus": "символическая ссылка — здесь удалить нельзя",
"protectedStatus": "содержит библиотеку рецептов — здесь удалить нельзя",
"unknownStatus": "не удалось проверить",
"deletedStatus": "удалено"
},
"folderResult": {
"unresolved": "Не удалось определить, в какой корневой папке моделей находится эта папка. Обновите боковую панель и повторите попытку."
},
"dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
@@ -1438,6 +1485,10 @@
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
"none": "Все {type}s в этой папке актуальны",
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
},
"scanFolder": "Сканировать эту папку",
"scanFolderResult": {
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку."
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ CivitAI",
"paid": "Платная",
"paidTooltip": "Скачивание этой версии платное",
"priceSaleTooltip": "Со скидкой, обычно {list} Buzz",
"priceBlueBuzzTooltip": "Можно также оплатить Blue Buzz",
"freeNow": "Теперь бесплатно",
"freeNowTooltip": "Эта версия больше не требует оплаты",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI"
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI",
"earlyAccessTooltipUntil": "Ранний доступ до {date}, после этого скачивание бесплатно"
},
"actions": {
"download": "Скачать",
@@ -2074,6 +2130,7 @@
"statSuccess": "Успешно",
"statFailed": "Ошибка",
"statSkipped": "Пропущено",
"statOffline": "Диск недоступен",
"statTotal": "Всего проверено",
"statDuration": "Длительность",
"successMessage": "Все {count} {type}s успешно обновлены",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
"moveFailed": "Не удалось переместить элемент: {message}",
"copiedToClipboard": "Скопировано в буфер обмена",
"downloadStarted": "Загрузка начата"
"downloadStarted": "Загрузка начата",
"refreshCompleteScoped": "{scope}: просканировано — новых {added}, удалено {removed}",
"refreshKeptUnreachable": "Сохранено моделей: {count} — {paths} недоступны",
"scanRootUnreachable": "{scope} сейчас недоступен. Изменений не внесено."
},
"agent": {
"llmNotConfigured": "Поставщик ИИ не настроен. Включите его в Настройки → Поставщик ИИ.",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "剩余时间不到一分钟",
"minutes": "剩余约 {minutes} 分钟",
"hours": "剩余约 {hours} 小时 {minutes} 分钟"
}
},
"walkFiles": "{count} 个文件"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "正在检查 {type} 更新...",
"success": "找到 {count} 条 {type} 更新",
"none": "所有 {type} 均已是最新版本",
"error": "检查 {type} 更新失败:{message}"
"error": "检查 {type} 更新失败:{message}",
"gateEvents": {
"becameFree": "{count} 个你未拥有的版本已免费",
"newGate": "{count} 个版本已变为付费"
}
},
"cleanupExampleImages": {
"label": "清理示例图片文件夹",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签",
"versionScope": "版本范围",
"priceTracking": "Buzz 下载价格",
"exampleImages": "示例图片",
"autoOrganize": "自动整理",
"metadata": "元数据",
@@ -759,6 +765,13 @@
"label": "隐藏付费更新",
"help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
},
"priceTracking": {
"help": "CivitAI 的公开 API 不提供下载价格,因此价格从模型页面读取 —— 每个付费模型多一次请求,按下面的间隔刷新。价格只显示在你尚未拥有的版本上。",
"enabled": "显示付费版本的下载价格",
"enabledHelp": "从模型页面读取下载价格,让付费与抢先体验版本显示所需费用,以及抢先体验的结束时间",
"refreshHours": "价格刷新间隔(小时)",
"refreshHoursHelp": "已保存的价格在重新读取前保持有效的时长"
},
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
@@ -905,7 +918,10 @@
"refresh": {
"title": "刷新模型列表",
"full": "重建缓存",
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。",
"scopeSection": "只扫描一个文件夹",
"rootOffline": "离线",
"rootModels": "{count} 个模型"
},
"fetch": {
"title": "从 CivitAI 获取元数据",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
"missingTitle": "未找到文件夹",
"missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
"symlinkTitle": "文件夹是符号链接",
"symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。",
"protectedTitle": "文件夹受保护",
"protectedMessage": "此文件夹存放着配方库,无法在 LoRA Manager 中删除。",
"busyTitle": "仍有删除操作待处理",
"checking": "正在检查文件夹内容...",
"confirm": "删除文件夹"
"confirm": "删除文件夹",
"confirmMulti": "删除 {count} 个文件夹",
"deleting": "正在删除...",
"keptNote": "未勾选的副本会让该文件夹继续显示在侧边栏中。"
},
"deleteFolderResult": {
"success": "已删除文件夹 \"{name}\"",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持删除文件夹",
"noRoot": "未配置模型根目录"
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
"symlink": "该文件夹是符号链接,无法从侧边栏中删除。",
"successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"",
"partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。"
},
"renameFolder": "重命名文件夹",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "此处已存在同名文件夹",
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
"unsupported": "此页面不支持重命名文件夹",
"noRoot": "未配置模型根目录"
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
"symlink": "该文件夹是符号链接,无法从侧边栏中重命名。"
},
"folderRoot": {
"chooseTitle": "该文件夹存在于多个模型根目录中",
"symlinkOption": "符号链接",
"rootsHint": "选择要从中删除该文件夹的模型根目录:",
"checkingStatus": "正在检查...",
"okStatus": "无模型",
"notEmptyStatus": "包含 {count} 个模型文件 —— 请先删除或移出这些模型",
"notEmptyExcludedStatus": "包含 {count} 个模型文件,其中 {excluded} 个已从模型库中排除",
"busyStatus": "仍有删除操作待处理",
"missingStatus": "磁盘上已不存在",
"symlinkStatus": "符号链接 —— 无法在此删除",
"protectedStatus": "存放配方库 —— 无法在此删除",
"unknownStatus": "无法检查",
"deletedStatus": "已删除"
},
"folderResult": {
"unresolved": "无法确定该文件夹位于哪个模型根目录。请刷新侧边栏后重试。"
},
"dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。",
@@ -1438,6 +1485,10 @@
"success": "在此文件夹中找到 {count} 个{type}更新",
"none": "此文件夹中的所有{type}都是最新版本",
"error": "检查文件夹{type}更新失败: {message}"
},
"scanFolder": "扫描此文件夹",
"scanFolderResult": {
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。"
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "此版本当前需要 CivitAI 抢先体验权限",
"paid": "付费",
"paidTooltip": "此版本需要付费后才能下载",
"priceSaleTooltip": "促销中,原价 {list} Buzz",
"priceBlueBuzzTooltip": "也可以使用 Blue Buzz 支付",
"freeNow": "现已免费",
"freeNowTooltip": "此版本已不再需要付费",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载"
"onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载",
"earlyAccessTooltipUntil": "抢先体验至 {date},之后可免费下载"
},
"actions": {
"download": "下载",
@@ -2074,6 +2130,7 @@
"statSuccess": "成功",
"statFailed": "失败",
"statSkipped": "已跳过",
"statOffline": "驱动器离线",
"statTotal": "总计扫描",
"statDuration": "耗时",
"successMessage": "全部 {count} 个 {type} 更新成功!",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "移动条目失败:{message}",
"copiedToClipboard": "已复制到剪贴板",
"downloadStarted": "下载已开始"
"downloadStarted": "下载已开始",
"refreshCompleteScoped": "已扫描 {scope}:新增 {added},移除 {removed}",
"refreshKeptUnreachable": "已保留 {count} 个模型:{paths} 当前不可访问",
"scanRootUnreachable": "{scope} 当前不可访问,未做任何改动。"
},
"agent": {
"llmNotConfigured": "AI 提供商未配置。请在 设置 → AI 提供商 中进行配置。",
+68 -8
View File
@@ -73,7 +73,8 @@
"lessThanMinute": "剩餘時間不到一分鐘",
"minutes": "剩餘約 {minutes} 分鐘",
"hours": "剩餘約 {hours} 小時 {minutes} 分鐘"
}
},
"walkFiles": "{count} 個檔案"
}
},
"onboarding": {
@@ -201,7 +202,11 @@
"loading": "正在檢查 {type} 更新...",
"success": "找到 {count} 個 {type} 更新",
"none": "所有 {type} 都是最新版本",
"error": "檢查 {type} 更新失敗:{message}"
"error": "檢查 {type} 更新失敗:{message}",
"gateEvents": {
"becameFree": "{count} 個您未擁有的版本已免費",
"newGate": "{count} 個版本已變為付費"
}
},
"cleanupExampleImages": {
"label": "清理範例圖片資料夾",
@@ -387,6 +392,7 @@
"downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤",
"versionScope": "版本範圍",
"priceTracking": "Buzz 下載價格",
"exampleImages": "範例圖片",
"autoOrganize": "自動整理",
"metadata": "中繼資料",
@@ -759,6 +765,13 @@
"label": "隱藏付費更新",
"help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
},
"priceTracking": {
"help": "CivitAI 的公開 API 不提供下載價格,因此價格從模型頁面讀取 —— 每個付費模型多一次請求,並依下方間隔重新整理。價格只顯示在您尚未擁有的版本上。",
"enabled": "顯示付費版本的下載價格",
"enabledHelp": "從模型頁面讀取下載價格,讓付費與搶先體驗版本顯示所需費用,以及搶先體驗的結束時間",
"refreshHours": "價格重新整理間隔(小時)",
"refreshHoursHelp": "已儲存的價格在重新讀取前保持有效的時長"
},
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
@@ -905,7 +918,10 @@
"refresh": {
"title": "重新整理模型列表",
"full": "重建快取",
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。",
"scopeSection": "只掃描一個資料夾",
"rootOffline": "離線",
"rootModels": "{count} 個模型"
},
"fetch": {
"title": "從 CivitAI 取得 metadata",
@@ -1396,9 +1412,18 @@
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
"missingTitle": "未找到資料夾",
"missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
"symlinkTitle": "資料夾是符號連結",
"symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。",
"protectedTitle": "資料夾受保護",
"protectedMessage": "此資料夾存放著配方庫,無法在 LoRA Manager 中刪除。",
"busyTitle": "仍有刪除操作待處理",
"checking": "正在檢查資料夾內容...",
"confirm": "刪除資料夾"
"confirm": "刪除資料夾",
"confirmMulti": "刪除 {count} 個資料夾",
"deleting": "正在刪除...",
"keptNote": "未勾選的副本會讓該資料夾繼續顯示在側邊欄中。"
},
"deleteFolderResult": {
"success": "已刪除資料夾 \"{name}\"",
@@ -1409,7 +1434,10 @@
"notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援刪除資料夾",
"noRoot": "未設定模型根目錄"
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
"symlink": "該資料夾是符號連結,無法從側邊欄中刪除。",
"successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"",
"partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。"
},
"renameFolder": "重新命名資料夾",
"renameFolderResult": {
@@ -1418,7 +1446,26 @@
"targetExists": "此處已存在同名資料夾",
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
"unsupported": "此頁面不支援重新命名資料夾",
"noRoot": "未設定模型根目錄"
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
"symlink": "該資料夾是符號連結,無法從側邊欄中重新命名。"
},
"folderRoot": {
"chooseTitle": "該資料夾存在於多個模型根目錄中",
"symlinkOption": "符號連結",
"rootsHint": "選擇要從中刪除該資料夾的模型根目錄:",
"checkingStatus": "正在檢查...",
"okStatus": "無模型",
"notEmptyStatus": "包含 {count} 個模型檔案 —— 請先刪除或移出這些模型",
"notEmptyExcludedStatus": "包含 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除",
"busyStatus": "仍有刪除操作待處理",
"missingStatus": "磁碟上已不存在",
"symlinkStatus": "符號連結 —— 無法在此刪除",
"protectedStatus": "存放配方庫 —— 無法在此刪除",
"unknownStatus": "無法檢查",
"deletedStatus": "已刪除"
},
"folderResult": {
"unresolved": "無法確定該資料夾位於哪個模型根目錄。請重新整理側邊欄後再試。"
},
"dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。",
@@ -1438,6 +1485,10 @@
"success": "在此資料夾中找到 {count} 個{type}更新",
"none": "此資料夾中的所有{type}都是最新版本",
"error": "檢查資料夾{type}更新失敗: {message}"
},
"scanFolder": "掃描此資料夾",
"scanFolderResult": {
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。"
}
},
"statistics": {
@@ -2016,10 +2067,15 @@
"earlyAccessTooltip": "此版本目前需要 CivitAI 搶先體驗權限",
"paid": "付費",
"paidTooltip": "此版本需要付費才能下載",
"priceSaleTooltip": "特價中,原價 {list} Buzz",
"priceBlueBuzzTooltip": "也可以使用 Blue Buzz 支付",
"freeNow": "現已免費",
"freeNowTooltip": "此版本已不再需要付費",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載"
"onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載",
"earlyAccessTooltipUntil": "搶先體驗至 {date},之後可免費下載"
},
"actions": {
"download": "下載",
@@ -2074,6 +2130,7 @@
"statSuccess": "成功",
"statFailed": "失敗",
"statSkipped": "已跳過",
"statOffline": "磁碟機離線",
"statTotal": "總計掃描",
"statDuration": "耗時",
"successMessage": "全部 {count} 個 {type} 更新成功!",
@@ -2758,7 +2815,10 @@
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "移動項目失敗:{message}",
"copiedToClipboard": "已複製到剪貼簿",
"downloadStarted": "下載已開始"
"downloadStarted": "下載已開始",
"refreshCompleteScoped": "已掃描 {scope}:新增 {added},移除 {removed}",
"refreshKeptUnreachable": "已保留 {count} 個模型:{paths} 目前無法存取",
"scanRootUnreachable": "{scope} 目前無法存取,未做任何變更。"
},
"agent": {
"llmNotConfigured": "AI 提供者尚未設定。請在 設定 → AI 提供者 中進行設定。",
+290 -7
View File
@@ -44,6 +44,26 @@ def _normalize_root_identity(path: str) -> str:
return normalized
def _append_new_paths(current: List[str], candidates: Iterable[str]) -> List[str]:
"""Return ``current`` plus any candidate it does not already hold.
Order is preserved and nothing is ever removed: ``*_roots[0]`` derives the
recipes directory and the usage-stats file location, so a mid-session change
to the root *order* would move user data.
"""
merged = list(current)
seen = {_normalize_root_identity(path) for path in merged}
for path in candidates:
if not isinstance(path, str) or not path.strip():
continue
identity = _normalize_root_identity(path)
if identity in seen:
continue
seen.add(identity)
merged.append(path)
return merged
def _resolve_valid_default_root(
current: str, primary_paths: List[str], allowed_paths: List[str], name: str
) -> str:
@@ -169,6 +189,12 @@ class Config:
self._preview_root_paths: Set[Path] = set()
# Fingerprint of the symlink layout from the last successful scan
self._cached_fingerprint: Optional[Dict[str, object]] = None
# Configured (existence-unfiltered) primary roots per model type. The live
# lists below drop paths whose directory is missing right now (a drive
# that is switched off), which must not be mistaken for "the user
# removed this path": these are what `save_folder_paths_to_settings()`
# persists and what `/roots` reports as unavailable.
self._configured_root_paths: Dict[str, List[str]] = {}
self.loras_roots = self._init_lora_paths()
self.checkpoints_roots = None
self.unet_roots = None
@@ -228,6 +254,8 @@ class Config:
if isinstance(recipes_path, str) and recipes_path:
self.recipes_path = recipes_path
self._remember_library_configured_paths(library_config)
extra_folder_paths = library_config.get("extra_folder_paths")
if not isinstance(extra_folder_paths, dict):
return
@@ -340,16 +368,40 @@ class Config:
comfy_library = libraries.get("comfyui", {})
default_library = libraries.get("default", {})
# Persist what is *configured*, not what is readable right now:
# `upsert_library(folder_paths=...)` replaces the library's paths, so
# writing the existence-filtered live lists would erase the path of a
# drive that happened to be switched off when ComfyUI started.
target_folder_paths = {
"loras": list(self.loras_roots),
"checkpoints": list(self.checkpoints_roots or []),
"unet": list(self.unet_roots or []),
"embeddings": list(self.embeddings_roots or []),
"loras": _append_new_paths(
self.configured_roots_for("lora"), self.loras_roots or []
),
"checkpoints": _append_new_paths(
self.configured_roots_for("checkpoint"),
self.checkpoints_roots or [],
),
"unet": _append_new_paths(
self.configured_roots_for("unet"), self.unet_roots or []
),
"embeddings": _append_new_paths(
self.configured_roots_for("embedding"),
self.embeddings_roots or [],
),
}
# Persist the other-model roots under their original folder_paths
# keys so library switching round-trips them.
for key, roots in (self.other_folder_roots or {}).items():
target_folder_paths[key] = list(roots)
# ...and keep an other-model root that is configured but unavailable.
configured_other = self.configured_roots_for("other")
if configured_other:
for key in self._get_enabled_other_folder_keys():
configured_key_roots = self._configured_other_paths_for_key(key)
if not configured_key_roots:
continue
target_folder_paths[key] = _append_new_paths(
configured_key_roots, target_folder_paths.get(key, [])
)
normalized_target_paths = _normalize_folder_paths_for_comparison(
target_folder_paths
@@ -420,7 +472,14 @@ class Config:
default_lora_root = _resolve_valid_default_root(
comfy_library.get("default_lora_root", ""),
list(self.loras_roots or []),
# A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"lora"
),
self.loras_roots or [],
),
list(self.loras_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []),
"default_lora_root",
@@ -428,7 +487,14 @@ class Config:
default_checkpoint_root = _resolve_valid_default_root(
comfy_library.get("default_checkpoint_root", ""),
list(self.checkpoints_roots or []),
# A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"checkpoint"
),
self.checkpoints_roots or [],
),
list(self.checkpoints_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []),
"default_checkpoint_root",
@@ -436,7 +502,14 @@ class Config:
default_embedding_root = _resolve_valid_default_root(
comfy_library.get("default_embedding_root", ""),
list(self.embeddings_roots or []),
# A configured-but-unavailable root is still a valid choice: it
# must not be "repaired" away just because its drive is off.
_append_new_paths(
self.configured_roots_for(
"embedding"
),
self.embeddings_roots or [],
),
list(self.embeddings_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []),
"default_embedding_root",
@@ -798,6 +871,16 @@ class Config:
except Exception as e:
logger.error(f"Error scanning links in {root}: {e}")
def iter_path_mappings(self) -> List[Tuple[str, str]]:
"""Return the known ``(physical target, virtual link)`` symlink pairs.
Only symlinks directly under a model root are tracked (see
:meth:`_scan_symbolic_links`), so callers must treat this as a partial
view of the on-disk link layout — enough to notice that a linked drive
went away, not enough to resolve nested links.
"""
return list(self._path_mappings.items())
def add_path_mapping(self, link_path: str, target_path: str):
"""Add a symbolic link path mapping
target_path: actual target path
@@ -1321,6 +1404,14 @@ class Config:
unet_paths = folder_paths.get("unet", []) or []
embedding_paths = folder_paths.get("embeddings", []) or []
# The snapshot is the authoritative configured set: unlike the live lists
# below it keeps paths whose directory is missing right now, and a path
# the user removed from the library is forgotten here.
self._remember_configured_paths("lora", lora_paths)
self._remember_configured_paths("checkpoint", checkpoint_paths)
self._remember_configured_paths("unet", unet_paths)
self._remember_configured_paths("embedding", embedding_paths)
self.loras_roots = self._prepare_lora_paths(lora_paths)
(
self.base_models_roots,
@@ -1333,6 +1424,10 @@ class Config:
key: folder_paths.get(key, []) or []
for key in self._get_enabled_other_folder_keys()
}
self._remember_configured_paths(
"other",
[path for paths in other_path_map.values() for path in paths],
)
(
self.other_roots,
self.other_root_subtypes,
@@ -1388,10 +1483,60 @@ class Config:
self._initialize_symlink_mappings()
def _remember_library_configured_paths(
self, library_config: Mapping[str, Any]
) -> None:
"""Remember the paths the library configures, missing directories included.
In standalone mode the host mock already filters non-existent paths out of
``folder_paths`` (see ``standalone.MockFolderPaths.get_folder_paths``), so
the library snapshot is the only record of what the user configured and it
wins there. In plugin mode ComfyUI's own list is unfiltered and therefore
authoritative — a path removed from the host config must be forgotten, not
resurrected from our mirror of it.
"""
if not standalone_mode:
return
from .services.settings_manager import get_settings_manager
folder_paths_map = (
library_config.get("folder_paths")
if isinstance(library_config, Mapping)
else None
)
if not isinstance(folder_paths_map, Mapping):
folder_paths_map = getattr(
get_settings_manager(), "settings", {}
).get("folder_paths", {})
if not isinstance(folder_paths_map, Mapping):
return
for key, model_type in (
("loras", "lora"),
("checkpoints", "checkpoint"),
("unet", "unet"),
("embeddings", "embedding"),
):
paths = folder_paths_map.get(key)
if isinstance(paths, (list, tuple)):
self._remember_configured_paths(model_type, paths)
core_keys = {"loras", "checkpoints", "unet", "embeddings"}
other_paths = [
path
for key, paths in folder_paths_map.items()
if key not in core_keys and isinstance(paths, (list, tuple))
for path in paths
]
if other_paths:
self._remember_configured_paths("other", other_paths)
def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings"""
try:
raw_paths = folder_paths.get_folder_paths("loras")
self._remember_configured_paths("lora", raw_paths)
unique_paths = self._prepare_lora_paths(raw_paths)
logger.info(
"Found LoRA roots:"
@@ -1412,6 +1557,8 @@ class Config:
try:
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet")
self._remember_configured_paths("checkpoint", raw_checkpoint_paths)
self._remember_configured_paths("unet", raw_unet_paths)
(
unique_paths,
self.checkpoints_roots,
@@ -1438,6 +1585,7 @@ class Config:
"""Initialize and validate embedding paths from ComfyUI settings"""
try:
raw_paths = folder_paths.get_folder_paths("embeddings")
self._remember_configured_paths("embedding", raw_paths)
unique_paths = self._prepare_embedding_paths(raw_paths)
logger.info(
"Found embedding roots:"
@@ -1475,6 +1623,10 @@ class Config:
except Exception as exc:
logger.debug("Error reading folder paths for '%s': %s", key, exc)
self._remember_configured_paths(
"other", [path for paths in folder_path_map.values() for path in paths]
)
(
unique_paths,
self.other_root_subtypes,
@@ -1495,6 +1647,137 @@ class Config:
logger.warning(f"Error initializing other model paths: {e}")
return []
def _remember_configured_paths(
self, model_type: str, paths: Iterable[str]
) -> None:
"""Record the configured roots of a model type (they may not exist yet).
Replaces the previous value rather than merging: every caller knows the
complete configured set for its type, so a path the user removed must be
forgotten here too.
"""
configured = [
path.strip()
for path in paths or []
if isinstance(path, str) and path.strip()
]
# Tolerate partially constructed instances (`Config.__new__`), which the
# path-resolution tests build to exercise a single initializer.
configured_map = getattr(self, "_configured_root_paths", None)
if configured_map is None:
configured_map = {}
self._configured_root_paths = configured_map
configured_map[model_type] = configured
def configured_roots_for(self, model_type: str) -> List[str]:
"""Configured roots of a model type, including ones that are unavailable.
``get_model_roots()`` on the scanners answers "what can be walked right
now"; this answers "what did the user (or the host) configure", which is
the set that must survive a switched-off drive in the configuration and
the set the refresh menu reports as offline.
"""
return list((getattr(self, "_configured_root_paths", None) or {}).get(model_type, []))
def admit_configured_roots(self) -> List[str]:
"""Append configured roots that became readable again, never removing any.
The live lists are built once at startup (and when a library snapshot is
applied), so a drive plugged in later is invisible until the process is
restarted. Re-running the per-type prepare helpers against the configured
paths and appending only the new entries is what makes "plug the drive
in, scan it" work without a restart. Nothing is ever dropped or
re-ordered — see ``_append_new_paths`` for why order matters.
Returns the paths that were admitted by this call.
"""
admitted: List[str] = []
def _extend(current, candidates) -> List[str]:
merged = _append_new_paths(list(current or []), candidates)
admitted.extend(
path for path in merged if path not in (current or [])
)
return merged
try:
lora_configured = self.configured_roots_for("lora")
if lora_configured:
self.loras_roots = _extend(
self.loras_roots, self._prepare_lora_paths(lora_configured)
)
checkpoint_configured = self.configured_roots_for("checkpoint")
unet_configured = self.configured_roots_for("unet")
if checkpoint_configured or unet_configured:
(
all_roots,
checkpoint_roots,
unet_roots,
) = self._prepare_checkpoint_paths(
checkpoint_configured, unet_configured
)
self.base_models_roots = _extend(self.base_models_roots, all_roots)
self.checkpoints_roots = _extend(self.checkpoints_roots, checkpoint_roots)
self.unet_roots = _extend(self.unet_roots, unet_roots)
embedding_configured = self.configured_roots_for("embedding")
if embedding_configured:
self.embeddings_roots = _extend(
self.embeddings_roots,
self._prepare_embedding_paths(embedding_configured),
)
other_configured = self.configured_roots_for("other")
if other_configured:
(
other_roots,
other_subtypes,
other_per_key,
) = self._prepare_other_paths(
{
key: [
path
for path in self._configured_other_paths_for_key(key)
]
for key in self._get_enabled_other_folder_keys()
}
)
self.other_roots = _extend(self.other_roots, other_roots)
for root, sub_type in other_subtypes.items():
self.other_root_subtypes.setdefault(root, sub_type)
for key, roots in other_per_key.items():
self.other_folder_roots[key] = _append_new_paths(
self.other_folder_roots.get(key, []), roots
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Failed to admit configured roots: %s", exc)
return admitted
if admitted:
logger.info(
"Admitted %d previously unavailable model root(s): %s",
len(admitted),
", ".join(admitted),
)
try:
# Previews of the newly admitted drive must be servable again.
self.refresh_preview_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh preview roots: %s", exc)
return admitted
def _configured_other_paths_for_key(self, key: str) -> List[str]:
"""Configured other-model paths for one folder_paths key (may not exist)."""
try:
return [
path.strip()
for path in folder_paths.get_folder_paths(key) or []
if isinstance(path, str) and path.strip()
]
except Exception:
return []
def refresh_other_roots(self) -> None:
"""Rebuild other-model roots after the management toggles changed.
+16 -12
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import inspect
import re
from typing import Any
@@ -22,20 +21,29 @@ def _stack_slot_number(name: str) -> int:
return 1 if letter == "a" else 2
class _LoraStackOptionalInputs:
"""Lookup that preserves explicit optional inputs and dynamic lora_stack slots."""
class _LoraStackOptionalInputs(dict):
"""Optional-input mapping that also resolves dynamically added stack slots.
Inheriting ``dict`` keeps ``INPUT_TYPES()`` JSON-serializable for ComfyUI's
``/object_info`` route: it serializes the stored entries, exactly as the plain
dict did before. The overridden ``__contains__``/``__getitem__`` let the
execution side resolve ``lora_stack3``-style inputs the frontend adds on
demand. This replaces the previous ``inspect.stack()`` check for the
``get_input_info`` caller, which the registry security scan reports as
anti-debugging.
"""
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
self._explicit_inputs = explicit_inputs
super().__init__(explicit_inputs)
def __contains__(self, item: object) -> bool:
if not isinstance(item, str):
return False
return item in self._explicit_inputs or _is_stack_input(item)
return super().__contains__(item) or _is_stack_input(item)
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
if key in self._explicit_inputs:
return self._explicit_inputs[key]
if super().__contains__(key):
return super().__getitem__(key)
if _is_stack_input(key):
return (
"LORA_STACK",
@@ -71,13 +79,9 @@ class LoraStackCombinerLM:
),
}
stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info":
optional_inputs = _LoraStackOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
return {
"required": {},
"optional": optional_inputs,
"optional": _LoraStackOptionalInputs(optional_inputs),
}
RETURN_TYPES = ("LORA_STACK",)
+16 -12
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from typing import Any
import inspect
from ..services.wildcard_service import (
contains_dynamic_syntax,
@@ -11,20 +10,29 @@ from ..services.wildcard_service import (
)
class _PromptOptionalInputs:
"""Lookup that preserves explicit optional inputs and dynamic trigger slots."""
class _PromptOptionalInputs(dict):
"""Optional-input mapping that also resolves dynamically added trigger slots.
Inheriting ``dict`` keeps ``INPUT_TYPES()`` JSON-serializable for ComfyUI's
``/object_info`` route: it serializes the stored entries, exactly as the plain
dict did before. The overridden ``__contains__``/``__getitem__`` let the
execution side resolve ``trigger_words3``-style inputs the frontend adds on
demand. This replaces the previous ``inspect.stack()`` check for the
``get_input_info`` caller, which the registry security scan reports as
anti-debugging.
"""
def __init__(self, explicit_inputs: dict[str, tuple[str, dict[str, Any]]]) -> None:
self._explicit_inputs = explicit_inputs
super().__init__(explicit_inputs)
def __contains__(self, item: object) -> bool:
if not isinstance(item, str):
return False
return item in self._explicit_inputs or is_trigger_words_input(item)
return super().__contains__(item) or is_trigger_words_input(item)
def __getitem__(self, key: str) -> tuple[str, dict[str, Any]]:
if key in self._explicit_inputs:
return self._explicit_inputs[key]
if super().__contains__(key):
return super().__getitem__(key)
if is_trigger_words_input(key):
return (
"STRING",
@@ -66,10 +74,6 @@ class PromptLM:
),
}
stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info":
optional_inputs = _PromptOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
return {
"required": {
"text": (
@@ -85,7 +89,7 @@ class PromptLM:
{"tooltip": "The CLIP model used for encoding the text."},
),
},
"optional": optional_inputs,
"optional": _PromptOptionalInputs(optional_inputs),
"hidden": {
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
+4 -2
View File
@@ -48,6 +48,7 @@ from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStat
from ...services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
from ...services.websocket_progress_callback import WebSocketBroadcastCallback
from ...utils.models import BaseModelMetadata
from ...utils.civitai_utils import build_civitai_model_page_url
from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR,
@@ -2880,9 +2881,10 @@ class ModelLibraryHandler:
try:
response = await metadata_provider.get_model_versions(model_id)
except ResourceNotFoundError:
except ResourceNotFoundError as exc:
return web.json_response(
{"success": False, "error": "Model not found"}, status=404
{"success": False, "error": str(exc) or "Model not found"},
status=404,
)
if not response or not response.get("modelVersions"):
return web.json_response(
+113 -18
View File
@@ -25,7 +25,11 @@ from ...services.connectivity_guard import (
is_expected_offline_error,
)
from ...services.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService
from ...services.model_file_service import (
ModelMoveService,
normalize_relative_folder,
)
from ...services.model_scanner import ReconcileScope
from ...services.preview_asset_service import PreviewAssetService
from ...services.service_registry import ServiceRegistry
from ...services.settings_manager import SettingsManager, get_settings_manager
@@ -50,6 +54,7 @@ from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload
from ...utils.file_utils import calculate_sha256
from ...utils.metadata_manager import MetadataManager
from ...utils.paid_access import is_early_access_deadline_active
from ...utils.sidecar_paths import get_metadata_path
from ...utils.url_utils import relative_root_prefix
@@ -1138,8 +1143,50 @@ class ModelQueryHandler:
async def scan_models(self, request: web.Request) -> web.Response:
try:
full_rebuild = request.query.get("full_rebuild", "false").lower() == "true"
await self._service.scan_models(
force_refresh=True, rebuild_cache=full_rebuild
requested_roots = [
value for value in request.query.getall("roots", []) if value
]
raw_folder = (request.query.get("folder") or "").strip()
if (requested_roots or raw_folder) and full_rebuild:
return web.json_response(
{
"error": "Scoped scans are not supported with "
"full_rebuild=true; a full rebuild always walks every root"
},
status=400,
)
folder = None
if raw_folder:
try:
folder = normalize_relative_folder(raw_folder)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
# A drive that was plugged in after startup must be scannable without
# a restart, and callers that never open the Refresh menu (the browser
# extension) have no other chance to admit it.
self._service.refresh_model_roots()
if requested_roots:
configured = self._service.get_model_roots()
unknown = [root for root in requested_roots if root not in configured]
if unknown:
return web.json_response(
{"error": "Unknown model root(s)", "roots": unknown}, status=400
)
# `folder` alone means "this relative folder in every root that has
# it" — the sidebar's unified tree carries no root identity.
scope = None
if requested_roots or folder:
scope = ReconcileScope(
roots=tuple(requested_roots) if requested_roots else None,
folder=folder,
)
summary = await self._service.scan_models(
force_refresh=True, rebuild_cache=full_rebuild, scope=scope
)
_broadcast_models_changed()
if self._service.scanner.is_cancelled():
@@ -1149,12 +1196,13 @@ class ModelQueryHandler:
"message": f"{self._service.model_type.capitalize()} scan cancelled",
}
)
return web.json_response(
{
payload: Dict[str, Any] = {
"status": "success",
"message": f"{self._service.model_type.capitalize()} scan completed",
}
)
if summary:
payload.update(summary)
return web.json_response(payload)
except Exception as exc:
self._logger.error(
"Error scanning %ss: %s", self._service.model_type, exc, exc_info=True
@@ -1163,8 +1211,18 @@ class ModelQueryHandler:
async def get_model_roots(self, request: web.Request) -> web.Response:
try:
# A drive plugged in after startup is admitted here, so the menu shows
# it as a normal row instead of "unavailable" until the next restart.
self._service.refresh_model_roots()
roots = self._service.get_model_roots()
return web.json_response({"success": True, "roots": roots})
try:
root_details = self._service.describe_model_roots()
except Exception as exc: # pragma: no cover - defensive
self._logger.debug("Root details unavailable: %s", exc)
root_details = []
return web.json_response(
{"success": True, "roots": roots, "root_details": root_details}
)
except Exception as exc:
self._logger.error(
"Error getting %s roots: %s",
@@ -2301,8 +2359,8 @@ class ModelCivitaiHandler:
metadata_provider = await self._metadata_provider_factory()
try:
response = await metadata_provider.get_model_versions(model_id)
except ResourceNotFoundError:
return web.Response(status=404, text="Model not found")
except ResourceNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
if not response or not response.get("modelVersions"):
return web.Response(status=404, text="Model not found")
@@ -2512,6 +2570,22 @@ class ModelMoveHandler:
self._logger.error("Error creating folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def resolve_folder(self, request: web.Request) -> web.Response:
"""Resolve a library-relative folder onto the roots that hold it.
The unified folder tree is a merged relative-path namespace, so the
client cannot tell which root a node came from; this is what lets the
sidebar act on the directory the user actually sees.
"""
try:
folder = request.query.get("folder", "")
result = self._move_service.resolve_folder(folder)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error resolving folder: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_folder(self, request: web.Request) -> web.Response:
try:
data = await request.json()
@@ -2941,6 +3015,20 @@ class ModelUpdateHandler:
same_base_scope = self._uses_same_base_update_scope()
# Gate/price transitions are reported for every refreshed model, not only
# for the ones that qualify as updates: "this version became free" matters
# for a version the user already has, which never shows up as an update.
events = []
for record in records.values():
for event in getattr(record, "events", None) or []:
events.append(
{
"modelId": record.model_id,
"modelType": record.model_type,
**event,
}
)
serialized_records = []
for record in records.values():
has_update_fn = getattr(record, "has_update", None)
@@ -2962,6 +3050,7 @@ class ModelUpdateHandler:
{
"success": True,
"records": serialized_records,
"events": events,
}
)
@@ -3391,6 +3480,7 @@ class ModelUpdateHandler:
hide_early_access=hide_early_access,
hide_paid=hide_paid,
),
"events": list(getattr(record, "events", None) or []),
"versions": [
self._serialize_version(version, context.get(version.version_id))
for version in record.versions
@@ -3414,16 +3504,11 @@ class ModelUpdateHandler:
if getattr(version, "is_paid", False) and not version.early_access_ends_at:
is_early_access = False
elif version.early_access_ends_at:
try:
from datetime import datetime, timezone
ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00")
# Shared with the update service and the download gate so the badge,
# the update filter and the download warning cannot disagree.
is_early_access = is_early_access_deadline_active(
version.early_access_ends_at
)
is_early_access = ea_date > datetime.now(timezone.utc)
except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative)
is_early_access = True
elif getattr(version, "is_early_access", False):
# Fallback to basic EA flag from bulk API
is_early_access = True
@@ -3450,6 +3535,15 @@ class ModelUpdateHandler:
"usageControl": version.usage_control,
"isPaid": bool(getattr(version, "is_paid", False)),
"paidAccess": paid_access_payload,
# Set when a version that used to be gated became free, so the UI can
# keep showing "Free" long after the transition.
"gateLapsedAt": getattr(version, "gate_lapsed_at", None),
"priceBuzz": getattr(version, "price_buzz", None),
"listPriceBuzz": getattr(version, "list_price_buzz", None),
"generationPriceBuzz": getattr(version, "generation_price_buzz", None),
"acceptsBlueBuzz": bool(getattr(version, "accepts_blue_buzz", False)),
"priceSaleEndsAt": getattr(version, "price_sale_ends_at", None),
"priceCheckedAt": getattr(version, "price_checked_at", None),
"filePath": context.get("file_path"),
"fileName": context.get("file_name"),
# Weight-file variant count (None when unknown); lets the UI hide
@@ -3593,6 +3687,7 @@ class ModelHandlerSet:
"move_model": self.move.move_model,
"move_models_bulk": self.move.move_models_bulk,
"create_folder": self.move.create_folder,
"resolve_folder": self.move.resolve_folder,
"delete_folder": self.move.delete_folder,
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models,
+11
View File
@@ -54,7 +54,18 @@ class PreviewHandler:
if not resolved.is_file():
logger.debug("Preview file not found at %s", str(resolved))
if resolved.parent.is_dir():
# The file is really gone from a reachable directory, so the
# cached preview_url is stale and can be cleared.
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
else:
# The directory itself is unreachable (drive switched off,
# unmounted share). Nothing was deleted: keep the cached
# preview_url so the card recovers when the drive is back.
logger.debug(
"Preview directory unreachable, keeping cached preview_url: %s",
str(resolved.parent),
)
raise web.HTTPNotFound(text="Preview file not found")
# aiohttp's FileResponse handles range requests, content headers, and
+2
View File
@@ -22,6 +22,8 @@ class RouteDefinition:
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
# App-wide and registered once: the alerts panel spans every model type, and
# the update DB is shared, so there is nothing per-type about it.
RouteDefinition("GET", "/api/lm/llm/models", "get_llm_models"),
RouteDefinition("GET", "/api/lm/llm/provider-models", "get_provider_models"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
+1
View File
@@ -61,6 +61,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"),
RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/{prefix}/resolve-folder", "resolve_folder"),
RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"),
RouteDefinition(
"GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"
+27 -5
View File
@@ -27,6 +27,7 @@ from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from .model_scanner import ReconcileScope
from .model_update_service import ModelUpdateService
@@ -936,12 +937,33 @@ class BaseModelService(ABC):
return self.scanner.get_hash_by_path(file_path)
async def scan_models(
self, force_refresh: bool = False, rebuild_cache: bool = False
):
"""Trigger model scanning"""
return await self.scanner.get_cached_data(
force_refresh=force_refresh, rebuild_cache=rebuild_cache
self,
force_refresh: bool = False,
rebuild_cache: bool = False,
scope: Optional["ReconcileScope"] = None,
) -> Optional[Dict[str, Any]]:
"""Trigger model scanning, optionally restricted to a scope.
Returns the reconcile summary (added / removed / repaired / skipped
roots / kept entries) for incremental scans, ``None`` for a full
rebuild.
"""
await self.scanner.get_cached_data(
force_refresh=force_refresh, rebuild_cache=rebuild_cache, scope=scope
)
return self.scanner.last_reconcile_summary
def describe_model_roots(self) -> List[Dict[str, Any]]:
"""Describe configured roots (label / reachability / cached count).
Pure read: callers that want a drive which was plugged in after startup to
appear as a normal row call :meth:`refresh_model_roots` first.
"""
return self.scanner.describe_model_roots()
def refresh_model_roots(self) -> List[str]:
"""Admit configured roots that became readable again (append-only)."""
return self.scanner.refresh_model_roots()
async def get_model_info_by_name(self, name: str):
"""Get model information by name"""
+174 -4
View File
@@ -20,7 +20,12 @@ from .model_metadata_provider import (
)
from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload
from ..utils.civitai_utils import (
build_civitai_model_page_url,
civitai_page_host_candidates,
resolve_license_payload,
)
from ..utils.civitai_page_prices import parse_model_page_prices
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash
logger = logging.getLogger(__name__)
@@ -31,6 +36,24 @@ logger = logging.getLogger(__name__)
_CREATOR_COUNT_CACHE_TTL_SECONDS = 600
_creator_model_count_cache: Dict[str, Tuple[float, Optional[int]]] = {}
# How long a page host stays on the skip list after refusing a request outright
# (Cloudflare's challenge, surfaced as 403 "Access forbidden"). Long enough to
# cover a whole update refresh, short enough to recover within a session.
_PAGE_HOST_BLOCK_TTL = 15 * 60
def _is_host_level_refusal(message: str) -> bool:
"""Whether a failed request means "this host refuses us" rather than "this
model is unavailable".
``downloader.make_request`` collapses statuses into prose, and 403 ("Access
forbidden") is the one a Cloudflare challenge produces. A 404 ("Resource not
found") is model-specific — mature pages are hidden from anonymous visitors —
so it must not put the whole host on the skip list.
"""
return "forbidden" in message.lower()
class CivitaiClient:
_instance = None
@@ -66,6 +89,10 @@ class CivitaiClient:
str, Tuple[Optional[Dict[str, Any]], Optional[str]]
] = OrderedDict()
self._MAX_CACHE_ENTRIES = 500
# Model-page host bookkeeping: which host last worked, and which ones are
# currently refusing us (see get_model_prices).
self._page_host_preference: Optional[str] = None
self._page_host_blocked: Dict[str, float] = {}
def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X&withMeta=true"
@@ -302,8 +329,19 @@ class CivitaiClient:
"name": result.get("name", ""),
}
message = self._extract_error_message(result)
if message and "not found" in message.lower():
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
if message and (
"not found" in message.lower()
or message.lower().startswith("no model with id ")
):
raise ResourceNotFoundError(
f"Civitai model {model_id} was not found. Check the original "
"Civitai link: use the model ID from /models/<id>, not a "
"version ID from ?modelVersionId=<id> or "
"/api/download/models/<id>. If the model ID is correct, "
"the model may have been removed or made inaccessible. "
"If the page opens only when logged in, check the Civitai "
"API key in LoRA Manager settings."
)
if is_expected_offline_error(message):
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
return None
@@ -320,7 +358,7 @@ class CivitaiClient:
except RateLimitError:
raise
except ResourceNotFoundError as exc:
logger.info("Model %s is no longer available on Civitai: %s", model_id, exc)
logger.info("Civitai model lookup failed: %s", exc)
raise
except Exception as e:
logger.error("Error fetching model versions: %s", e, exc_info=True)
@@ -383,6 +421,138 @@ class CivitaiClient:
logger.error(f"Error fetching model versions in bulk: {exc}")
return None
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
"""Fetch per-version buzz prices for one model from its public page.
CivitAI's public REST API deliberately omits prices, but the model page
embeds the site's own ``model.getById`` result (including
``paidAccess.terms``) in its server-rendered payload. One request covers
every version of the model. Returns ``{version_id: price fields}``, an
empty dict when the page loads but lists no gated version, or None when
the page could not be read or understood — callers keep any stored price.
Several hosts are tried in order, because the hosts are not equivalent:
* ``civitai.red`` serves mature model pages that ``civitai.com`` hides from
anonymous visitors, but it is behind a Cloudflare challenge that refuses
non-browser clients outright (403 for any User-Agent).
* ``civitai.com`` / ``civitai.green`` answer normally for anonymously
visible models, and 404 for the mature ones.
So the user's ``civitai_host`` preference is a starting point, not the only
option. Mature models whose page cannot be read from any host stay
priceless — see the known limitation in
``docs/plans/paid-model-price-tracking.md``.
This is a public anonymous page fetch: no API key and no internal
endpoint, so a failure here must never fail the update check itself.
"""
try:
normalized_id = int(model_id)
except (TypeError, ValueError):
return None
candidates = self._page_host_candidates()
failures: List[str] = []
for host in candidates:
url = build_civitai_model_page_url(normalized_id, host=host)
if not url:
continue
try:
success, result = await self._make_request(
"GET",
url,
use_auth=False,
custom_headers={"Accept": "text/html"},
)
except RateLimitError:
# The shared rate-limit gate already recorded it; skip this model.
raise
except Exception as exc: # pragma: no cover - defensive
failures.append(f"{host}: {exc}")
continue
if not success or not isinstance(result, str):
message = result if isinstance(result, str) else type(result).__name__
failures.append(f"{host}: {message}")
if isinstance(result, str) and _is_host_level_refusal(result):
# A refusal like Cloudflare's "Access forbidden" applies to the
# host, not to this model, so stop paying for it for a while.
self._block_page_host(host)
continue
prices = parse_model_page_prices(result)
if prices is None:
failures.append(f"{host}: no usable price payload")
continue
self._remember_page_host(host)
return prices
logger.warning(
"No price source for model %s; tried %s. Mature models are only served "
"by civitai.red, which challenges non-browser clients.",
model_id,
"; ".join(failures) or "no candidate hosts",
)
return None
def _page_host_candidates(self) -> List[str]:
"""Ordered hosts to try: the last one that worked, then the preference."""
preferred = self._page_host()
ordered = list(civitai_page_host_candidates(preferred))
if self._page_host_preference and self._page_host_preference in ordered:
ordered.remove(self._page_host_preference)
ordered.insert(0, self._page_host_preference)
now = time.time()
usable = [
host
for host in ordered
if now - self._page_host_blocked.get(host, 0.0) >= _PAGE_HOST_BLOCK_TTL
]
# Never return an empty list: a blocked host is still better than no attempt
# once the preference and the memo disagree.
return usable or ordered
def _remember_page_host(self, host: str) -> None:
if self._page_host_preference != host:
logger.info("CivitAI model pages are being read from %s", host)
self._page_host_preference = host
self._page_host_blocked.pop(host, None)
def _block_page_host(self, host: str) -> None:
if host in self._page_host_blocked:
return
# Log once per host per TTL: the failure is host-wide, so repeating it for
# every mature model in the library would be pure noise.
logger.warning(
"CivitAI model pages on %s refused the request (likely a Cloudflare "
"challenge); skipping that host for %d minutes",
host,
_PAGE_HOST_BLOCK_TTL // 60,
)
self._page_host_blocked[host] = time.time()
if self._page_host_preference == host:
self._page_host_preference = None
def _page_host(self) -> Optional[str]:
"""Resolve the page host from the ``civitai_host`` setting."""
try:
from .settings_manager import get_settings_manager
settings = get_settings_manager()
return settings.get("civitai_host") if settings else None
except Exception:
return None
async def get_model_version(
self, model_id: int | None = None, version_id: int | None = None
) -> Optional[Dict[str, Any]]:
+41 -28
View File
@@ -33,6 +33,13 @@ from ..utils.constants import (
VALID_OTHER_CIVITAI_TYPES,
)
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.paid_access import (
is_early_access_deadline_active,
is_gate_active,
is_permanent_paid,
normalize_paid_access,
parse_civitai_timestamp,
)
from ..utils.file_utils import calculate_sha256, calculate_autov3
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
@@ -63,8 +70,11 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
# Hosts a model download may hit (metadata + file transfer). The pre-flight
# cooldown gate consults the RateLimitCoordinator for these before a download
# occupies a concurrency slot.
DOWNLOAD_PREFLIGHT_HOSTS = ("civitai.com", "civitai.red", "civarchive.com")
# occupies a concurrency slot. civarchive.com is only gated for downloads
# whose source is CivArchive: a cooldown armed by background metadata fetches
# must not block plain CivitAI downloads.
DOWNLOAD_PREFLIGHT_HOSTS = ("civitai.com", "civitai.red")
DOWNLOAD_PREFLIGHT_HOSTS_CIVARCHIVE = DOWNLOAD_PREFLIGHT_HOSTS + ("civarchive.com",)
# Fallback retry_after when neither the vendor nor the coordinator can supply
# a number (matches the Retry-After parsing default in downloader.py).
@@ -251,19 +261,30 @@ class DownloadManager:
hostname = urlparse(url).hostname
return hostname.lower() if hostname else "unknown"
async def _preflight_rate_limit_error(self) -> Optional[DownloadRateLimitError]:
async def _preflight_rate_limit_error(
self, source: str | None = None
) -> Optional[DownloadRateLimitError]:
"""Fail fast when a download target host is in a rate-limit cooldown.
Consults the RateLimitCoordinator's per-host cooldown state for the
hosts a model download may hit. Runs BEFORE the concurrency semaphore
hosts this download may hit. Runs BEFORE the concurrency semaphore
is acquired so queued items never occupy a slot during a 429 episode.
Deliberately non-blocking: the caller is expected to pace itself (the
companion extension auto-pauses on the structured 429 response).
civarchive.com is only consulted when ``source == "civarchive"`` —
other downloads never touch it, so a cooldown armed there (typically
by bulk metadata fetches) must not block them.
"""
hosts = (
DOWNLOAD_PREFLIGHT_HOSTS_CIVARCHIVE
if source == "civarchive"
else DOWNLOAD_PREFLIGHT_HOSTS
)
coordinator = await RateLimitCoordinator.get_instance()
worst_host: Optional[str] = None
worst_remaining = 0.0
for host in DOWNLOAD_PREFLIGHT_HOSTS:
for host in hosts:
remaining = coordinator.remaining_seconds(host)
if remaining > worst_remaining:
worst_host = host
@@ -671,7 +692,7 @@ class DownloadManager:
# Pre-flight cooldown gate: fail fast (without holding a semaphore
# slot) when a target host is still cooling down from an earlier 429.
preflight_error = await self._preflight_rate_limit_error()
preflight_error = await self._preflight_rate_limit_error(source)
if preflight_error is not None:
logger.info(
"Download %s skipped: %s", task_id, preflight_error
@@ -1985,40 +2006,32 @@ class DownloadManager:
os.makedirs(save_dir, exist_ok=True)
# Check if this is a paid or early access model
paid_access = version_info.get("paidAccess")
if isinstance(paid_access, str):
# Some providers (e.g. CivArchive fallback) carry the DTO as JSON text
try:
parsed = json.loads(paid_access)
paid_access = parsed if isinstance(parsed, dict) else None
except (TypeError, ValueError):
paid_access = None
if not isinstance(paid_access, dict):
paid_access = None
# An empty DTO ({"permanent": false, "endsAt": null}) is not a gate
if paid_access and not paid_access.get("permanent") and not paid_access.get("endsAt"):
paid_access = None
if version_info.get("earlyAccessEndsAt") or paid_access:
permanent_paid = bool(paid_access.get("permanent")) if paid_access else False
# CivitAI reports a non-null paidAccess only for an ACTIVE gate, so
# {"permanent": false, "endsAt": null} (a timed gate whose end is not
# recorded yet) still counts as gated here.
paid_access = normalize_paid_access(version_info.get("paidAccess"))
legacy_ea_ends_at = version_info.get("earlyAccessEndsAt")
gate_active = is_gate_active(paid_access) or is_early_access_deadline_active(
legacy_ea_ends_at
)
if gate_active:
permanent_paid = is_permanent_paid(paid_access)
if permanent_paid:
early_access_msg = (
"This model requires payment. Please ensure you have "
"purchased access and are logged in to Civitai."
)
else:
early_access_date = version_info.get("earlyAccessEndsAt")
early_access_date = legacy_ea_ends_at
if not early_access_date and paid_access:
early_access_date = paid_access.get("endsAt")
if not early_access_date:
early_access_date = ""
# Convert to a readable date if possible
try:
from datetime import datetime
date_obj = datetime.fromisoformat(
early_access_date.replace("Z", "+00:00")
)
formatted_date = date_obj.strftime("%Y-%m-%d")
formatted_date = parse_civitai_timestamp(
early_access_date
).strftime("%Y-%m-%d")
early_access_msg = (
f"This model requires payment (until {formatted_date}). "
)
+6
View File
@@ -45,6 +45,12 @@ class ResourceNotFoundError(RuntimeError):
pass
class MetadataPersistError(RuntimeError):
"""Raised when the metadata sidecar cannot be written to disk."""
pass
class LLMNotConfiguredError(RuntimeError):
"""Raised when an LLM-dependent operation is attempted but no provider is configured."""
+11 -2
View File
@@ -14,7 +14,7 @@ from ..utils.model_utils import determine_base_model
from ..utils.models import autov3_from_civitai_files
from ..utils.sidecar_paths import get_metadata_path
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
from .errors import RateLimitError
from .errors import MetadataPersistError, RateLimitError
from .model_metadata_provider import _LOCAL_PROVIDER_LABELS
from .model_sources import get_source_platform, has_external_source
@@ -231,7 +231,16 @@ class MetadataSyncService:
metadata_path, local_metadata, civitai_metadata.get("images", [])
)
await self._metadata_manager.save_metadata(metadata_path, local_metadata)
saved = await self._metadata_manager.save_metadata(metadata_path, local_metadata)
if not saved:
# A swallowed write failure would update the cache while the
# durable sidecar never lands (e.g. the drive went offline), and
# the model would be skipped by every later fetch as "already
# fetched". Fail loudly instead so the caller reports the item
# and it stays eligible for the next run.
raise MetadataPersistError(
f"Failed to write metadata sidecar: {metadata_path}"
)
return local_metadata
async def fetch_and_update_model(
+136 -6
View File
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE, MODEL_FILE_EXTENSIONS
from ..utils.recipes_paths import path_is_or_contains_recipes_dir
from ..utils.sidecar_paths import is_centralized, resolve_centralized_dir_for_dir
from ..services.settings_manager import get_settings_manager
from ..services.model_lifecycle_service import _require_path_in_library_roots
@@ -16,6 +17,33 @@ from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
logger = logging.getLogger(__name__)
def normalize_relative_folder(folder: str) -> str:
"""Normalize a library-relative folder path, raising ``ValueError``.
Absolute paths (POSIX, drive-letter or UNC) and paths that climb out of the
library root are refused: both mean the caller is confused about which space
it is working in, and guessing would be worse than an error. Shared by the
folder operations and the scoped scan endpoint so both reject the same input.
"""
raw = str(folder or "").strip()
if not raw:
raise ValueError("Folder path is required")
normalized = raw.replace("\\", "/")
if normalized.startswith("/") or (len(normalized) > 1 and normalized[1] == ":"):
raise ValueError("Folder path must be relative to a library root")
normalized = os.path.normpath(normalized)
if (
normalized == ".."
or normalized.startswith("../")
or normalized.startswith(".." + os.sep)
):
raise ValueError("Folder path must stay inside the library root")
return normalized
def _normalize_match_path(path: Any) -> str:
"""Normalize a path for set membership tests.
@@ -561,6 +589,64 @@ class ModelMoveService:
return rel.replace(os.sep, "/")
return ""
def resolve_folder(self, folder: str) -> Dict[str, Any]:
"""Map a library-relative folder onto the directories it stands for.
The unified folder tree merges every model root into a single
relative-path namespace, so a tree node does not carry the root it came
from: the same relative folder can exist under several roots, or under
one that is not the default root. Folder operations take an absolute
business path, so a caller holding only the rendered node must ask which
directories it maps to instead of assuming a root — assuming one is how
deleting a folder that sits right there fails with "Folder no longer
exists", or, when a same-named folder exists in another root, silently
acts on that other directory.
Args:
folder: Library-relative folder path (``a/b``; backslashes are
accepted). Absolute paths are rejected — a caller that already
holds an absolute business path does not need this.
Returns:
Dictionary with the success flag, the normalized relative folder and
``candidates``: one entry per model root that holds the directory on
disk (``folder_path``/``root``/``is_symlink``), in scanner root
order. An empty list means the directory exists under no root.
"""
try:
relative = self._normalize_relative_folder(folder)
except ValueError as exc:
return {"success": False, "error": str(exc)}
candidates: List[Dict[str, Any]] = []
for root in self.scanner.get_model_roots():
abs_root = os.path.abspath(root)
candidate = os.path.abspath(os.path.join(abs_root, relative))
# normpath already collapsed any "." / ".." segment; the
# containment check is belt-and-braces, not traversal defence.
if candidate != abs_root and not candidate.startswith(abs_root + os.sep):
continue
if not os.path.isdir(candidate):
continue
candidates.append(
{
"folder_path": candidate.replace(os.sep, "/"),
"root": abs_root.replace(os.sep, "/"),
"is_symlink": os.path.islink(candidate),
}
)
return {
"success": True,
"folder": relative.replace(os.sep, "/"),
"candidates": candidates,
}
@staticmethod
def _normalize_relative_folder(folder: str) -> str:
"""Normalize a library-relative folder path, raising ``ValueError``."""
return normalize_relative_folder(folder)
async def delete_folder(self, folder_path: str, dry_run: bool = False) -> Dict[str, Any]:
"""Delete a model-free directory inside the model library roots.
@@ -598,7 +684,13 @@ class ModelMoveService:
"error": "Symlinked folders cannot be deleted",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
# `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation.
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
if self._is_model_root(absolute_path):
return {
@@ -606,6 +698,19 @@ class ModelMoveService:
"error": "The library root itself cannot be deleted",
}
if path_is_or_contains_recipes_dir(absolute_path):
# The recipes dir holds no model weight files, so the
# model_count check below cannot protect it — but removing it
# wipes the user's recipe library.
return {
"success": False,
"code": "protected",
"error": (
"This folder holds the recipe library and cannot be "
"deleted from here"
),
}
manifest = self._collect_folder_manifest(absolute_path)
if manifest["pending_delete_job"]:
@@ -679,7 +784,7 @@ class ModelMoveService:
except OSError: # pragma: no cover - best-effort cleanup
pass
await self._forget_folder(relative_folder)
await self._forget_folder(relative_folder, absolute_path)
return {
"success": True,
@@ -789,13 +894,20 @@ class ModelMoveService:
return set()
return {_normalize_match_path(path) for path in paths if path}
async def _forget_folder(self, relative_folder: str) -> None:
"""Drop a removed directory from the scanner's folder/cache records."""
async def _forget_folder(
self, relative_folder: str, absolute_path: Optional[str] = None
) -> None:
"""Drop a removed directory from the scanner's folder/cache records.
``absolute_path`` is what the caller actually deleted: the scanner needs
it to purge the right model cards, because the same relative folder can
exist under several roots.
"""
if not relative_folder:
return
remove_known_folder = getattr(self.scanner, "remove_known_folder", None)
if callable(remove_known_folder):
await remove_known_folder(relative_folder)
await remove_known_folder(relative_folder, absolute_path)
async def rename_folder(self, folder_path: str, new_name: str) -> Dict[str, Any]:
"""Rename a directory inside the model library roots.
@@ -836,7 +948,13 @@ class ModelMoveService:
"error": "Symlinked folders cannot be renamed",
}
if not os.path.isdir(absolute_path):
return {"success": False, "error": "Folder no longer exists"}
# `missing` is its own code so the sidebar can tell "this node's
# directory is gone, refresh" apart from a failed operation.
return {
"success": False,
"code": "missing",
"error": "Folder no longer exists",
}
if self._is_model_root(absolute_path):
return {
@@ -844,6 +962,18 @@ class ModelMoveService:
"error": "The library root itself cannot be renamed",
}
if path_is_or_contains_recipes_dir(absolute_path):
# Renaming the recipes dir (or an ancestor) strands the recipe
# library: the scanner keeps looking at the configured path.
return {
"success": False,
"code": "protected",
"error": (
"This folder holds the recipe library and cannot be "
"renamed from here"
),
}
previous_relative = self._calculate_relative_folder(absolute_path)
target = os.path.join(os.path.dirname(absolute_path), new_name)
+53
View File
@@ -183,6 +183,17 @@ class ModelMetadataProvider(ABC):
"""
return None
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
"""Fetch per-version buzz prices for a model, when the provider has them.
CivitAI publishes prices only inside its public model page payload;
providers that cannot read it (CivArchive, SQLite, OpenModelDB) keep the
default of None, which callers treat as "no price information".
"""
return None
class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata"""
@@ -222,6 +233,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
) -> Optional[Dict[str, Any]]:
return await self.client.get_version_file_mini(version_id, file_id)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
return await self.client.get_model_prices(model_id)
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive API for metadata"""
@@ -783,6 +799,34 @@ class FallbackMetadataProvider(ModelMetadataProvider):
def _iter_providers(self):
return zip(self.providers, self._provider_labels)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result = await self._call_with_rate_limit(
label,
provider.get_model_prices,
model_id,
)
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug("Provider %s failed for get_model_prices: %s", label, e)
continue
return None
def excluding(self, labels: "frozenset[str] | set[str]") -> "FallbackMetadataProvider":
"""Return a copy of this chain without the providers named in *labels*.
@@ -907,6 +951,15 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
file_id,
)
async def get_model_prices(
self, model_id: int
) -> Optional[Dict[int, Dict[str, Any]]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_model_prices,
model_id,
)
class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers"""
+1215 -137
View File
File diff suppressed because it is too large Load Diff
+449 -32
View File
@@ -11,7 +11,7 @@ import logging
import os
import sqlite3
import time
from dataclasses import dataclass, replace
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence
@@ -20,10 +20,25 @@ from .settings_manager import get_settings_manager
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.paid_access import (
is_early_access_deadline_active,
normalize_paid_access as _normalize_paid_access_payload,
)
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
logger = logging.getLogger(__name__)
# Version fields a price payload may set (see py/utils/civitai_page_prices.py).
_PRICE_FIELD_NAMES = frozenset(
{
"price_buzz",
"list_price_buzz",
"generation_price_buzz",
"accepts_blue_buzz",
"price_sale_ends_at",
}
)
def _normalize_int(value) -> Optional[int]:
"""Safely convert a value to an integer."""
@@ -36,6 +51,16 @@ def _normalize_int(value) -> Optional[int]:
return None
def _format_utc_timestamp(value: float) -> str:
"""Format a POSIX timestamp the way CivitAI serializes its timestamps."""
return (
datetime.fromtimestamp(value, tz=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
def _normalize_string(value) -> Optional[str]:
"""Return a stripped string or None if the value is empty."""
@@ -82,6 +107,24 @@ class ModelVersionRecord:
# e.g. records persisted before this field existed or locally-synthesized
# entries). Mirrors the frontend isModelWeightFile() filter.
file_count: Optional[int] = None
# When a version that used to carry a paid gate stopped carrying one. Kept so
# the UI can mark a version "free" long after the transition happened; cleared
# again if the version is gated once more.
gate_lapsed_at: Optional[str] = None
# Download price in Buzz for the gated version, and friends. All None until a
# price refresh succeeds (see the price tracking feature); `price_buzz` is the
# effective price a buyer pays now (sale-adjusted) and `list_price_buzz` the
# undiscounted stored price.
price_buzz: Optional[int] = None
list_price_buzz: Optional[int] = None
generation_price_buzz: Optional[int] = None
accepts_blue_buzz: bool = False
price_sale_ends_at: Optional[str] = None
price_checked_at: Optional[float] = None
# When a price fetch was last *attempted* (success or failure). Distinguishes
# "never tried" from "tried and no price is readable", which is what lets the
# UI say "price unavailable" for mature models instead of showing nothing.
price_check_attempted_at: Optional[float] = None
@dataclass
@@ -93,6 +136,12 @@ class ModelUpdateRecord:
versions: List[ModelVersionRecord]
last_checked_at: Optional[float]
should_ignore_model: bool
# Gate-state and price transitions observed during the refresh that produced
# this record. Derived, never persisted: a record read back from SQLite has an
# empty list. Each entry is {"versionId", "kind", "versionName", "isInLibrary"}
# with kind in {"new_gate", "became_free"}. Only versions the user does not
# have produce events.
events: List[Dict[str, Any]] = field(default_factory=list)
@property
def largest_version_id(self) -> Optional[int]:
@@ -172,16 +221,10 @@ class ModelUpdateRecord:
if version.is_paid and not version.early_access_ends_at:
return False
# Phase 2: Precise check with exact end time
# Phase 2: Precise check with exact end time (None when the gate is timed
# but its window end has not been recorded yet -> treated as active below)
if version.early_access_ends_at:
try:
ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00")
)
return ea_date > datetime.now(timezone.utc)
except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative)
return True
return is_early_access_deadline_active(version.early_access_ends_at)
# Phase 1: Basic EA flag from bulk API
return version.is_early_access
@@ -324,6 +367,14 @@ class ModelUpdateService:
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
file_count INTEGER,
gate_lapsed_at TEXT,
price_buzz INTEGER,
list_price_buzz INTEGER,
generation_price_buzz INTEGER,
accepts_blue_buzz INTEGER NOT NULL DEFAULT 0,
price_sale_ends_at TEXT,
price_checked_at REAL,
price_check_attempted_at REAL,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
);
@@ -575,12 +626,59 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"ADD COLUMN file_count INTEGER"
),
"gate_lapsed_at": (
"ALTER TABLE model_update_versions "
"ADD COLUMN gate_lapsed_at TEXT"
),
"price_buzz": (
"ALTER TABLE model_update_versions "
"ADD COLUMN price_buzz INTEGER"
),
"list_price_buzz": (
"ALTER TABLE model_update_versions "
"ADD COLUMN list_price_buzz INTEGER"
),
"generation_price_buzz": (
"ALTER TABLE model_update_versions "
"ADD COLUMN generation_price_buzz INTEGER"
),
"accepts_blue_buzz": (
"ALTER TABLE model_update_versions "
"ADD COLUMN accepts_blue_buzz INTEGER NOT NULL DEFAULT 0"
),
"price_sale_ends_at": (
"ALTER TABLE model_update_versions "
"ADD COLUMN price_sale_ends_at TEXT"
),
"price_checked_at": (
"ALTER TABLE model_update_versions "
"ADD COLUMN price_checked_at REAL"
),
"price_check_attempted_at": (
"ALTER TABLE model_update_versions "
"ADD COLUMN price_check_attempted_at REAL"
),
}
for column, statement in migrations.items():
if column not in version_columns:
conn.execute(statement)
# Columns this feature carried while it was still in development. They are
# gone from the schema, so only a database created by an unreleased build
# can still have them; for everyone else this is a no-op. `DROP COLUMN`
# needs SQLite 3.35+, hence the guard.
for obsolete in ("price_alert_state", "price_alert_since"):
if obsolete not in version_columns:
continue
try:
conn.execute(
f"ALTER TABLE model_update_versions DROP COLUMN {obsolete}"
)
logger.info("Dropped obsolete column %s", obsolete)
except sqlite3.OperationalError as exc: # pragma: no cover - old SQLite
logger.debug("Could not drop obsolete column %s: %s", obsolete, exc)
# Refresh column metadata after applying additive migrations.
version_columns = self._get_table_columns(conn, "model_update_versions")
@@ -679,6 +777,14 @@ class ModelUpdateService:
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
file_count INTEGER,
gate_lapsed_at TEXT,
price_buzz INTEGER,
list_price_buzz INTEGER,
generation_price_buzz INTEGER,
accepts_blue_buzz INTEGER NOT NULL DEFAULT 0,
price_sale_ends_at TEXT,
price_checked_at REAL,
price_check_attempted_at REAL,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
)
@@ -701,6 +807,14 @@ class ModelUpdateService:
"paid_access",
"is_paid",
"file_count",
"gate_lapsed_at",
"price_buzz",
"list_price_buzz",
"generation_price_buzz",
"accepts_blue_buzz",
"price_sale_ends_at",
"price_checked_at",
"price_check_attempted_at",
]
defaults = {
"sort_index": "0",
@@ -716,6 +830,14 @@ class ModelUpdateService:
"paid_access": "NULL",
"is_paid": "0",
"file_count": "NULL",
"gate_lapsed_at": "NULL",
"price_buzz": "NULL",
"list_price_buzz": "NULL",
"generation_price_buzz": "NULL",
"accepts_blue_buzz": "0",
"price_sale_ends_at": "NULL",
"price_checked_at": "NULL",
"price_check_attempted_at": "NULL",
}
select_parts = []
@@ -1170,6 +1292,37 @@ class ModelUpdateService:
elif fallback_attempted and fallback_error_message is None:
fallback_error_message = "no response"
# Optional price capture. Runs after the version list is known so only the
# models that actually carry a gate pay for a second (page) request, and
# stays outside the lock along with the other network I/O.
#
# Deliberately independent of the metadata TTL: the cached record already
# carries the gate, so enabling price tracking (or letting the *price* TTL
# lapse) must not wait for the version list to go stale. Otherwise a user
# who turns the feature on prices only the handful of models that happened
# to need a metadata refresh that round.
priced_versions: Optional[List[ModelVersionRecord]] = None
price_candidates: Optional[Sequence[ModelVersionRecord]] = None
if refresh_succeeded and isinstance(fetched_versions, list) and fetched_versions:
price_candidates = fetched_versions
elif existing is not None and existing.versions:
price_candidates = existing.versions
if (
price_candidates
and metadata_provider is not None
and self._should_fetch_prices(
price_candidates, existing, force=force_refresh
)
):
price_candidates = await self._apply_model_prices(
metadata_provider, model_id, price_candidates
)
if refresh_succeeded and isinstance(fetched_versions, list):
fetched_versions = list(price_candidates)
else:
priced_versions = list(price_candidates)
if fallback_attempted:
if refresh_succeeded and isinstance(fetched_versions, list):
logger.info(
@@ -1234,6 +1387,11 @@ class ModelUpdateService:
local_base_models=local_base_models,
)
else:
if priced_versions is not None and existing is not None:
# Metadata came from the cache, but the prices did not: keep
# them without touching last_checked_at, so the metadata TTL is
# not silently extended by a price-only pass.
existing = replace(existing, versions=priced_versions)
record = self._merge_with_local_versions(
existing,
normalized_local,
@@ -1633,10 +1791,55 @@ class ModelUpdateService:
existing_map = {version.version_id: version for version in existing.versions} if existing else {}
versions: List[ModelVersionRecord] = []
events: List[Dict[str, Any]] = []
seen_ids: set[int] = set()
lapse_timestamp = _format_utc_timestamp(timestamp)
for index, remote_version in enumerate(remote_versions):
version_id = remote_version.version_id
seen_ids.add(version_id)
existing_version = existing_map.get(version_id)
is_gated = self._has_structural_gate(remote_version)
was_gated = (
self._has_structural_gate(existing_version)
if existing_version is not None
else None
)
should_ignore = ignore_map.get(version_id, remote_version.should_ignore)
# `gate_lapsed_at` survives refreshes so a version that became free keeps
# its "free since" marker, and is cleared if the gate comes back.
if is_gated:
gate_lapsed_at = None
elif was_gated:
gate_lapsed_at = lapse_timestamp
elif existing_version is not None:
gate_lapsed_at = existing_version.gate_lapsed_at
else:
gate_lapsed_at = None
price_fields = self._price_fields_for(
remote_version, existing_version, is_gated=is_gated
)
# Only the edge fires, and only for a version the user does not have:
# a version already on disk cannot become cheaper *for them*, and a
# version that stays gated must not re-announce itself every refresh.
in_library = version_id in effective_local_set
if (
existing_version is not None
and not should_ignore
and not in_library
and was_gated != is_gated
):
events.append(
{
"versionId": version_id,
"kind": "new_gate" if is_gated else "became_free",
"versionName": remote_version.name,
"isInLibrary": False,
}
)
versions.append(
ModelVersionRecord(
version_id=version_id,
@@ -1646,7 +1849,7 @@ class ModelUpdateService:
size_bytes=remote_version.size_bytes,
preview_url=remote_version.preview_url or preview_map.get(version_id),
is_in_library=version_id in effective_local_set,
should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
should_ignore=should_ignore,
sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
@@ -1658,6 +1861,8 @@ class ModelUpdateService:
if remote_version.file_count is not None
else file_count_map.get(version_id)
),
gate_lapsed_at=gate_lapsed_at,
**price_fields,
)
)
@@ -1696,8 +1901,199 @@ class ModelUpdateService:
versions=self._sorted_versions(versions),
last_checked_at=timestamp,
should_ignore_model=existing.should_ignore_model if existing else False,
events=events,
)
@staticmethod
def _has_structural_gate(version: Optional[ModelVersionRecord]) -> bool:
"""True when a version carries a paid gate, ignoring the clock.
Transition detection uses this rather than a time-based check so a gate
that lapsed since the previous refresh still reads as "was gated" — the
public API reports no ``paidAccess`` at all once a gate expires, so the
time-based helper alone would silently see "free before, free now".
"""
if version is None:
return False
return bool(version.paid_access) or version.is_paid or version.is_early_access
@staticmethod
def _price_fields_for(
remote_version: ModelVersionRecord,
existing_version: Optional[ModelVersionRecord],
*,
is_gated: bool,
) -> Dict[str, Any]:
"""Resolve the price columns for a refreshed version.
A refresh only carries prices when a price fetch actually ran (recognizable
by ``price_checked_at``), so otherwise the previously stored price is kept —
but only while the version is still gated: once it is free the stored
numbers are stale and must not keep firing alerts.
"""
if not is_gated:
return {
"price_buzz": None,
"list_price_buzz": None,
"generation_price_buzz": None,
"accepts_blue_buzz": False,
"price_sale_ends_at": None,
"price_checked_at": None,
"price_check_attempted_at": None,
}
if remote_version.price_checked_at is not None:
source = remote_version
else:
source = existing_version or remote_version
return {
"price_buzz": source.price_buzz,
"list_price_buzz": source.list_price_buzz,
"generation_price_buzz": source.generation_price_buzz,
"accepts_blue_buzz": source.accepts_blue_buzz,
"price_sale_ends_at": source.price_sale_ends_at,
"price_checked_at": source.price_checked_at,
"price_check_attempted_at": source.price_check_attempted_at,
}
def _price_tracking_enabled(self) -> bool:
"""Whether the optional (page-fetching) price capture is switched on."""
try:
return bool(self._settings.get("price_tracking_enabled", False))
except Exception:
return False
def _price_check_ttl_seconds(self) -> float:
try:
hours = float(self._settings.get("price_check_ttl_hours", 24))
except (TypeError, ValueError):
hours = 24.0
if hours <= 0:
hours = 24.0
return hours * 3600.0
def _should_fetch_prices(
self,
remote_versions: Sequence[ModelVersionRecord],
existing: Optional[ModelUpdateRecord],
*,
force: bool = False,
) -> bool:
"""Whether this model needs a price fetch this round.
Only gated versions are considered, so an unmodified free library pays
nothing. A stored price is refreshed once its TTL lapses, and immediately
when the gate itself changed (a new end date or sale window is a reason to
believe the price moved).
A *failed* attempt counts as an attempt: without that, a mature model whose
page no host will serve would be retried on every single update check.
``force`` (the user asked explicitly) overrides the TTL.
"""
if not self._price_tracking_enabled():
return False
if force:
return any(
self._has_structural_gate(version) for version in remote_versions
)
existing_map = (
{version.version_id: version for version in existing.versions}
if existing
else {}
)
now = time.time()
ttl = self._price_check_ttl_seconds()
for remote_version in remote_versions:
if not self._has_structural_gate(remote_version):
continue
stored = existing_map.get(remote_version.version_id)
if stored is None:
return True
anchor = stored.price_checked_at or stored.price_check_attempted_at
if anchor is None:
return True
if (now - anchor) >= ttl:
return True
if (stored.paid_access or None) != (remote_version.paid_access or None):
return True
return False
async def _apply_model_prices(
self,
metadata_provider,
model_id: int,
versions: Sequence[ModelVersionRecord],
) -> List[ModelVersionRecord]:
"""Attach freshly fetched prices to the versions that have them.
Never raises for a provider problem: price tracking is a convenience, and
an unreadable page (or a provider that has no prices at all) must leave
the update check exactly as it was.
A failed attempt is still recorded (``price_check_attempted_at``) so the UI
can distinguish "we could not read a price" from "we never looked" — that
is the honest state for mature models, whose pages are served only by
civitai.red, which refuses non-browser clients.
"""
getter = getattr(metadata_provider, "get_model_prices", None)
if not callable(getter):
return list(versions)
attempted_at = time.time()
try:
prices = await getter(model_id)
except RateLimitError:
raise
except Exception as exc: # pragma: no cover - defensive
logger.debug("Price fetch failed for model %s: %s", model_id, exc)
prices = None
if not isinstance(prices, Mapping):
prices = {}
enriched: List[ModelVersionRecord] = []
for version in versions:
if not self._has_structural_gate(version):
enriched.append(version)
continue
fields = prices.get(version.version_id)
recognized = (
{
key: value
for key, value in fields.items()
if key in _PRICE_FIELD_NAMES
}
if isinstance(fields, Mapping)
else {}
)
if not recognized:
# Keep the stored price (there may be none) but remember the try, so
# the UI can say "unavailable" instead of showing nothing at all.
enriched.append(
replace(version, price_check_attempted_at=attempted_at)
)
continue
enriched.append(
replace(
version,
price_checked_at=attempted_at,
price_check_attempted_at=attempted_at,
**recognized,
)
)
return enriched
def _sorted_versions(self, versions: Sequence[ModelVersionRecord]) -> List[ModelVersionRecord]:
ordered = sorted(versions, key=lambda version: (version.sort_index, version.version_id))
normalized: List[ModelVersionRecord] = []
@@ -1762,6 +2158,9 @@ class ModelUpdateService:
# CivitAI's paidAccess DTO ({"permanent": bool, "endsAt": ISO|null})
# gates versions behind a paid tier while availability stays "Public".
# A non-null DTO from the public API is always an ACTIVE gate: lapsed
# (tombstone) gates come back as null. That includes the timed gate whose
# end is not recorded yet, {"permanent": false, "endsAt": null}.
paid_access = self._normalize_paid_access(entry.get("paidAccess"))
paid_access_json = json.dumps(paid_access) if paid_access else None
is_paid = bool(paid_access.get("permanent")) if paid_access else False
@@ -1769,7 +2168,7 @@ class ModelUpdateService:
early_access_ends_at = _normalize_string(paid_access.get("endsAt"))
# Only timed gates are early access; permanent paid versions are not
# (consumers filter them via is_paid), so the stored flag stays accurate.
if not is_early_access and paid_access and paid_access.get("endsAt"):
if not is_early_access and paid_access and not paid_access.get("permanent"):
is_early_access = True
return ModelVersionRecord(
@@ -1797,24 +2196,12 @@ class ModelUpdateService:
Accepts a dict, None, or a JSON string (as carried by the by-hash
enrichment path) and returns ``{"permanent": bool, "endsAt": str|None}``
or None when the input carries no paid-access signal.
Delegates to :mod:`py.utils.paid_access` so the update service and the
download gate cannot disagree about what counts as a gate.
"""
if value is None:
return None
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
value = parsed
if not isinstance(value, Mapping):
return None
permanent = bool(value.get("permanent"))
ends_at = _normalize_string(value.get("endsAt"))
if not permanent and ends_at is None:
return None
return {"permanent": permanent, "endsAt": ends_at}
return _normalize_paid_access_payload(value)
@staticmethod
def _extract_file_count(files) -> Optional[int]:
@@ -1947,7 +2334,10 @@ class ModelUpdateService:
f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid, file_count
is_early_access, usage_control, paid_access, is_paid, file_count,
gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at,
price_check_attempted_at
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1979,6 +2369,22 @@ class ModelUpdateService:
paid_access=row["paid_access"],
is_paid=bool(row["is_paid"]),
file_count=_normalize_int(row["file_count"]),
gate_lapsed_at=row["gate_lapsed_at"],
price_buzz=_normalize_int(row["price_buzz"]),
list_price_buzz=_normalize_int(row["list_price_buzz"]),
generation_price_buzz=_normalize_int(row["generation_price_buzz"]),
accepts_blue_buzz=bool(row["accepts_blue_buzz"]),
price_sale_ends_at=row["price_sale_ends_at"],
price_checked_at=(
float(row["price_checked_at"])
if row["price_checked_at"] is not None
else None
),
price_check_attempted_at=(
float(row["price_check_attempted_at"])
if row["price_check_attempted_at"] is not None
else None
),
)
)
@@ -2041,8 +2447,11 @@ class ModelUpdateService:
INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid, file_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_early_access, usage_control, paid_access, is_paid, file_count,
gate_lapsed_at, price_buzz, list_price_buzz, generation_price_buzz,
accepts_blue_buzz, price_sale_ends_at, price_checked_at,
price_check_attempted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -2061,6 +2470,14 @@ class ModelUpdateService:
paid_access_value,
1 if version.is_paid else 0,
version.file_count,
version.gate_lapsed_at,
version.price_buzz,
version.list_price_buzz,
version.generation_price_buzz,
1 if version.accepts_blue_buzz else 0,
version.price_sale_ends_at,
version.price_checked_at,
version.price_check_attempted_at,
),
)
conn.commit()
+3 -15
View File
@@ -2676,25 +2676,13 @@ class RecipeScanner:
@property
def recipes_dir(self) -> str:
"""Get path to recipes directory"""
from .settings_manager import get_settings_manager
from ..utils.recipes_paths import get_effective_recipes_dir
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
recipes_dir = os.path.abspath(
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
)
recipes_dir = get_effective_recipes_dir()
if recipes_dir:
os.makedirs(recipes_dir, exist_ok=True)
return recipes_dir
if not config.loras_roots:
return ""
# config.loras_roots already sorted case-insensitively, use the first one
recipes_dir = os.path.join(config.loras_roots[0], "recipes")
os.makedirs(recipes_dir, exist_ok=True)
return recipes_dir
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
"""Get cached recipe data, refresh if needed"""
# If a background initialization is in progress, wait for it to
+39 -28
View File
@@ -130,6 +130,12 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"model_card_footer_action": "replace_preview",
"show_version_on_card": True,
"version_grouping": "same_base",
# Buzz price tracking for paid/early-access versions. Opt-in because reading a
# price costs one extra (public) model-page request per gated model.
# Plumbing switch: reading a price costs one extra request per paid model, so
# it stays opt-in. Prices decorate the version list; nothing alerts on a number.
"price_tracking_enabled": False,
"price_check_ttl_hours": 24,
"auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],
"skip_previously_downloaded_model_versions": False,
@@ -577,6 +583,39 @@ class SettingsManager:
if changed and initial_bootstrap:
self._needs_initial_save = True
self._warn_if_multi_library()
def _warn_if_multi_library(self) -> None:
"""Surface a deprecation notice when more than one library is configured."""
libraries = self.settings.get("libraries", {})
if not isinstance(libraries, Mapping) or len(libraries) <= 1:
return
names = ", ".join(sorted(str(name) for name in libraries))
logger.warning(
"Multi-library support is deprecated and will be removed in an "
"upcoming release. Detected %d libraries in settings.json (%s). "
"Multiple folder roots within a single library remain fully "
"supported; please consolidate your libraries into one.",
len(libraries),
names,
)
self._add_startup_message(
code="multi-library-deprecated",
title="Multi-library support is deprecated",
message=(
f"This installation has {len(libraries)} libraries configured in "
f"settings.json ({names}). Multi-library support is deprecated and "
"will be removed in an upcoming release; at that point all "
"libraries' folder paths will be merged into a single library. "
"Multiple folder roots within a single library remain fully "
"supported, so consider consolidating into one library now."
),
severity="warning",
actions=self._default_settings_actions(),
dismissible=True,
)
def _sync_active_library_to_root(self, *, save: bool = False) -> None:
"""Update top-level folder path settings to mirror the active library."""
libraries = self.settings.get("libraries", {})
@@ -2376,34 +2415,6 @@ class SettingsManager:
if was_active:
self._notify_library_change(self.settings["active_library"])
def update_active_library_paths(
self,
folder_paths: Mapping[str, Iterable[str]],
*,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
) -> None:
"""Update folder paths for the active library."""
active_name = self.get_active_library_name()
self.upsert_library(
active_name,
folder_paths=folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
default_other_roots=default_other_roots,
recipes_path=recipes_path,
activate=True,
)
def _notify_library_change(self, library_name: str) -> None:
"""Notify dependent services that the active library changed."""
libraries = self.settings.get("libraries", {})
@@ -7,6 +7,7 @@ import time
from typing import Any, Dict, List, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService
from ..model_scanner import _normalized_root_prefix, _path_matches_prefix
from ..model_sources import has_external_source
from ...utils.metadata_manager import MetadataManager
@@ -46,7 +47,7 @@ class BulkMetadataRefreshUseCase:
enable_metadata_archive_db = self._settings.get("enable_metadata_archive_db", False)
skip_paths = self._settings.get("metadata_refresh_skip_paths", [])
to_process: Sequence[Dict[str, Any]] = [
eligible: Sequence[Dict[str, Any]] = [
model
for model in cache.raw_data
if not model.get("skip_metadata_refresh", False)
@@ -68,6 +69,26 @@ class BulkMetadataRefreshUseCase:
)
]
# Models on a drive that cannot be read right now stay in the cache
# (scoped scan keeps them), but fetching their metadata would only
# spend CivitAI rate limit on sidecar/preview writes that cannot land.
offline_roots = self._offline_root_details()
if offline_roots:
to_process = [
model
for model in eligible
if not self._is_under_offline_root(model.get("file_path", ""), offline_roots)
]
else:
to_process = list(eligible)
offline_skipped = len(eligible) - len(to_process)
if offline_skipped:
self._logger.info(
"Bulk metadata refresh: %d model(s) skipped, drive offline: %s",
offline_skipped,
", ".join(root["label"] for root in offline_roots),
)
total_to_process = len(to_process)
initial_skipped = total_models - total_to_process # models excluded from fetch queue
processed = 0
@@ -90,14 +111,21 @@ class BulkMetadataRefreshUseCase:
"success": success,
"failure_count": len(failures),
"skipped_count": skipped_count,
"offline_skipped": offline_skipped,
"handled": handled_count,
"elapsed_seconds": int(time.monotonic() - start_time),
}
# Only include full failure details in terminal emits (completed,
# cancelled, rate_limited) to avoid serializing the list on every
# per-model progress update.
if failures and status in ("completed", "cancelled", "rate_limited"):
if status in ("completed", "cancelled", "rate_limited"):
if failures:
payload["failures"] = failures
if offline_roots:
payload["offline_roots"] = [
{"path": root["path"], "label": root["label"]}
for root in offline_roots
]
payload.update(extra)
await progress_callback.on_progress(payload)
@@ -110,7 +138,7 @@ class BulkMetadataRefreshUseCase:
if self._service.scanner.is_cancelled():
self._logger.info("Bulk metadata refresh cancelled by user")
await emit("cancelled", processed=processed, success=success)
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "offline_skipped": offline_skipped, "elapsed_seconds": int(time.monotonic() - start_time)}
try:
original_name = model.get("model_name")
@@ -202,6 +230,7 @@ class BulkMetadataRefreshUseCase:
"failures": failures,
"failure_count": len(failures),
"skipped_count": skipped_count,
"offline_skipped": offline_skipped,
"elapsed_seconds": int(time.monotonic() - start_time),
}
@@ -237,8 +266,58 @@ class BulkMetadataRefreshUseCase:
"Successfully updated "
f"{success} of {processed} processed {self._service.model_type}s (total: {total_models})"
)
if offline_skipped:
message += (
f"; {offline_skipped} skipped, drive offline ("
+ ", ".join(root["label"] for root in offline_roots)
+ ")"
)
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "elapsed_seconds": int(time.monotonic() - start_time)}
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models, "failures": failures, "failure_count": len(failures), "skipped_count": skipped_count, "offline_skipped": offline_skipped, "elapsed_seconds": int(time.monotonic() - start_time)}
def _offline_root_details(self) -> List[Dict[str, str]]:
"""Configured roots whose directory cannot be read right now.
Uses the scanner's root description (live ``os.path.exists`` per root),
so both a drive switched off at startup and one unplugged mid-session
are covered. Root probing must never break a fetch: any failure yields
an empty list, which disables the offline filter.
"""
describe = getattr(self._service.scanner, "describe_model_roots", None)
if describe is None:
return []
try:
details = describe()
except Exception as exc: # pragma: no cover - defensive logging
self._logger.debug("Failed to describe model roots: %s", exc)
return []
offline: List[Dict[str, str]] = []
for detail in details:
if not isinstance(detail, dict) or detail.get("reachable", True):
continue
path = detail.get("path") or ""
if not path:
continue
offline.append(
{
"path": path,
"label": detail.get("label") or path,
"prefix": _normalized_root_prefix(path),
}
)
return offline
@staticmethod
def _is_under_offline_root(
file_path: str, offline_roots: Sequence[Dict[str, str]]
) -> bool:
"""Match a cached business path against offline root prefixes."""
if not file_path:
return False
return any(
_path_matches_prefix(file_path, root["prefix"]) for root in offline_roots
)
@staticmethod
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
+257
View File
@@ -0,0 +1,257 @@
"""Extract buzz prices from a public CivitAI model page.
CivitAI's public REST API deliberately omits prices: ``paidAccess`` is trimmed to
``{permanent, endsAt}`` because "pricing belongs to the purchase flow" (see the
upstream ``toPublicPaidAccessDto``). The model *page*, however, ships the site's
own ``model.getById`` result inside its server-rendered Next.js payload, and that
payload carries the full ``paidAccess.terms`` — download price, generation price,
sale and the Blue Buzz flag.
Reading it is a plain anonymous page fetch: no API key, no internal endpoint, no
forged ``Origin``. It is still page data rather than a contract, so nothing in
this module may raise: an unrecognized shape degrades to ``None`` and the caller
keeps whatever price it already stored.
Shape this understands (verified against civitai.com and civitai.red)::
<script id="__NEXT_DATA__" type="application/json">
{"props": {"pageProps": {"trpcState": {"json": {"queries": [
{"queryKey": [["model", "getById"], {...}],
"state": {"data": {"modelVersions": [
{"id": 3379626, "paidAccess": {
"endsAt": null, "timeframeDays": null,
"terms": {"download": {"price": 5000},
"generation": {"price": 100, "trialLimit": 5}},
"sale": null}}
]}}}
]}}}}}
</script>
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, Iterator, Mapping, Optional
from .paid_access import is_gate_active, normalize_paid_access
logger = logging.getLogger(__name__)
__all__ = ["parse_model_page_prices", "MAX_PAGE_BYTES"]
# Bound the payload before parsing: a model page is a few hundred KB, so anything
# far larger is not a page we want to hold in memory.
MAX_PAGE_BYTES = 8 * 1024 * 1024
_NEXT_DATA_RE = re.compile(
r'<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>',
re.DOTALL,
)
# The procedure that carries per-version pricing on a model page.
_MODEL_QUERY_PATH = ("model", "getById")
def parse_model_page_prices(html: Any) -> Optional[Dict[int, Dict[str, Any]]]:
"""Return ``{version_id: price fields}`` for a model page, or None.
Only versions with an *active* gate appear: the page payload also carries
lapsed gates as tombstones (a version with ``endsAt`` in the past is freely
downloadable), and those must not be reported as priced.
Returns None when the page carries no usable payload at all (missing script
tag, malformed JSON, unexpected structure, challenge page).
"""
if not isinstance(html, str) or not html:
return None
if len(html) > MAX_PAGE_BYTES:
logger.debug("CivitAI model page too large to parse (%d bytes)", len(html))
return None
match = _NEXT_DATA_RE.search(html)
if match is None:
return None
try:
payload = json.loads(match.group(1))
except (TypeError, ValueError):
return None
versions = _find_model_versions(payload)
if versions is None:
return None
prices: Dict[int, Dict[str, Any]] = {}
for entry in versions:
if not isinstance(entry, Mapping):
continue
version_id = _coerce_int(entry.get("id"))
if version_id is None:
continue
price_fields = _price_fields(entry.get("paidAccess"))
if price_fields is not None:
prices[version_id] = price_fields
return prices
def _find_model_versions(payload: Any) -> Optional[list]:
"""Locate ``modelVersions`` inside the dehydrated tRPC state.
The query order is not stable (several procedures are dehydrated per page), so
the query is selected by key rather than position.
"""
if not isinstance(payload, Mapping):
return None
for state in _iter_trpc_states(payload):
queries = state.get("queries")
if not isinstance(queries, list):
continue
for query in queries:
if not isinstance(query, Mapping):
continue
if not _matches_model_query(query.get("queryKey")):
continue
data = (query.get("state") or {}).get("data")
if isinstance(data, Mapping):
versions = data.get("modelVersions")
if isinstance(versions, list):
return versions
return None
def _iter_trpc_states(payload: Mapping[str, Any]) -> Iterator[Mapping[str, Any]]:
"""Yield the dehydrated tRPC state objects found under ``props.pageProps``."""
props = payload.get("props")
if not isinstance(props, Mapping):
return
page_props = props.get("pageProps")
if not isinstance(page_props, Mapping):
return
candidates = [page_props.get("trpcState")]
# Older/other Next.js builds keep the state one level deeper.
dehydrated = page_props.get("dehydratedState")
if dehydrated is not None:
candidates.append(dehydrated)
for candidate in candidates:
if not isinstance(candidate, Mapping):
continue
for key in ("json", "superjson", "dehydratedState"):
nested = candidate.get(key)
if isinstance(nested, Mapping):
yield nested
yield candidate
def _matches_model_query(query_key: Any) -> bool:
if not isinstance(query_key, (list, tuple)) or not query_key:
return False
first = query_key[0]
if not isinstance(first, (list, tuple)) or len(first) < 2:
return False
return str(first[0]) == _MODEL_QUERY_PATH[0] and str(first[1]) == _MODEL_QUERY_PATH[1]
def _price_fields(raw_paid_access: Any) -> Optional[Dict[str, Any]]:
"""Build the price fields for one version, or None when it is not gated."""
dto = _coerce_dto(raw_paid_access)
if dto is None:
return None
info = normalize_paid_access(dto)
if not info or not is_gate_active(info):
return None
terms = dto.get("terms")
if not isinstance(terms, Mapping):
terms = {}
list_price = _download_price(terms)
effective_price = list_price
sale = dto.get("sale")
sale_ends_at = None
if isinstance(sale, Mapping):
sale_ends_at = _coerce_str(sale.get("endsAt"))
buyer_terms = sale.get("buyerTerms")
if isinstance(buyer_terms, Mapping):
sale_price = _download_price(buyer_terms)
if sale_price is not None:
effective_price = sale_price
return {
"price_buzz": effective_price,
"list_price_buzz": list_price,
"generation_price_buzz": _generation_price(terms),
"accepts_blue_buzz": bool(terms.get("acceptsBlueBuzz")),
"price_sale_ends_at": sale_ends_at,
}
def _coerce_dto(value: Any) -> Optional[Mapping[str, Any]]:
if isinstance(value, str):
try:
value = json.loads(value)
except (TypeError, ValueError):
return None
return value if isinstance(value, Mapping) else None
def _download_price(terms: Mapping[str, Any]) -> Optional[int]:
download = terms.get("download")
if not isinstance(download, Mapping):
return None
return _coerce_price(download.get("price"))
def _generation_price(terms: Mapping[str, Any]) -> Optional[int]:
generation = terms.get("generation")
if not isinstance(generation, Mapping):
return None
# `{free: true}` has no price of its own, and a paid tier may omit `price` to
# fall back to the download price — the download price is already captured
# separately, so only an explicit number is reported here.
return _coerce_price(generation.get("price"))
def _coerce_price(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
price = int(value)
return price if price >= 0 else None
if isinstance(value, str):
try:
price = int(float(value.strip()))
except (TypeError, ValueError):
return None
return price if price >= 0 else None
return None
def _coerce_int(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value.strip())
except (TypeError, ValueError):
return None
return None
def _coerce_str(value: Any) -> Optional[str]:
if isinstance(value, str) and value.strip():
return value.strip()
return None
+18 -2
View File
@@ -41,6 +41,21 @@ def normalize_civitai_page_host(hostname: str | None) -> str:
return DEFAULT_CIVITAI_PAGE_HOST
def civitai_page_host_candidates(hostname: str | None = None) -> tuple[str, ...]:
"""Ordered page hosts to try, the preferred one first.
The hosts are not interchangeable: ``civitai.red`` serves mature model pages
that ``civitai.com`` hides from anonymous visitors, but it also sits behind a
Cloudflare challenge that rejects non-browser HTTP clients outright. Trying the
others when the preferred host refuses a request is what keeps a user's
``civitai_host`` preference from disabling page reads entirely.
"""
preferred = normalize_civitai_page_host(hostname)
rest = sorted(_SUPPORTED_CIVITAI_PAGE_HOSTS - {preferred})
return (preferred, *rest)
def build_civitai_model_page_url(
model_id: str | int | None,
version_id: str | int | None = None,
@@ -133,7 +148,8 @@ def extract_civitai_model_url_parts(
if parsed is None:
return None, None
path_match = re.search(r"/models/(\d+)", parsed.path)
# Download URLs contain version IDs, not model IDs.
path_match = re.match(r"^/models/(\d+)(?:/|$)", parsed.path)
if not path_match:
return None, None
@@ -152,7 +168,7 @@ def extract_civitai_image_id(url: str | None) -> str | None:
if parsed is None:
return None
path_match = re.search(r"/images/(\d+)", parsed.path)
path_match = re.match(r"^/images/(\d+)(?:/|$)", parsed.path)
if not path_match:
return None
+158
View File
@@ -0,0 +1,158 @@
"""Shared interpretation of CivitAI's ``paidAccess`` DTO.
One rule for the whole backend: the update service (badges, update filters, price
tracking) and the download gate both ask these helpers, so a version can never be
"paid" for one consumer and "free" for another.
Why the rules look like this
----------------------------
CivitAI's public v1 API only returns a non-null DTO for an *active* gate: a lapsed
gate stays in the database as a tombstone but is filtered out server-side
(``toPublicPaidAccessDto`` in civitai's ``server/services/paid-access.service.ts``).
A timed gate whose end time has not been recorded yet is reported as
``{"permanent": false, "endsAt": null}`` and is still enforced — such a version
reports ``canDownload: false`` on the model page — so it must NOT be discarded.
Dropping it (the previous behaviour) hid real gates, which is the class of bug
reported in issue #1060.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Mapping, Optional
__all__ = [
"normalize_paid_access",
"is_permanent_paid",
"is_gate_active",
"is_early_access_deadline_active",
"parse_civitai_timestamp",
]
# A DTO that carries neither key is not a gate signal at all (defensive: CivitAI
# never emits a bare ``{}``, but other metadata sources might).
_PAID_ACCESS_KEYS = ("permanent", "endsAt")
def parse_civitai_timestamp(value: Any) -> Optional[datetime]:
"""Parse a CivitAI ISO-8601 timestamp into an aware UTC datetime.
Returns None for anything that is not a parsable string. Naive timestamps are
assumed to be UTC, matching CivitAI's serialization.
"""
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
if text.endswith("Z"):
text = f"{text[:-1]}+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def normalize_paid_access(value: Any) -> Optional[dict]:
"""Normalize a ``paidAccess`` value into ``{"permanent": bool, "endsAt": str|None}``.
Accepts a mapping, a JSON string (the by-hash enrichment path carries the DTO as
text), or None. Returns None when the value carries no gate signal.
Note that ``{"permanent": False, "endsAt": None}`` is a *gate*: CivitAI reports
it for a timed gate whose window end is not set yet, and enforces it.
"""
if value is None:
return None
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
value = parsed
if not isinstance(value, Mapping):
return None
if not any(key in value for key in _PAID_ACCESS_KEYS):
return None
ends_at = value.get("endsAt")
normalized_ends_at = ends_at.strip() if isinstance(ends_at, str) and ends_at.strip() else None
return {
"permanent": bool(value.get("permanent")),
"endsAt": normalized_ends_at,
}
def is_permanent_paid(paid_access: Optional[Mapping[str, Any]]) -> bool:
"""True when the gate never expires (a permanent paid version)."""
return bool(paid_access and paid_access.get("permanent"))
def is_gate_active(
paid_access: Optional[Mapping[str, Any]],
*,
now: Optional[datetime] = None,
) -> bool:
"""True when a normalized ``paidAccess`` gate is currently in force.
Active means permanent, or a timed gate whose end is either still in the future
or not recorded yet (CivitAI enforces the latter too). An unparsable end time is
treated as active rather than free: the download would fail anyway, so the
conservative reading matches what the user will experience.
"""
if not paid_access:
return False
if paid_access.get("permanent"):
return True
ends_at = paid_access.get("endsAt")
if not ends_at:
# Timed gate with no recorded end — CivitAI still gates the download.
return True
parsed = parse_civitai_timestamp(ends_at)
if parsed is None:
return True
reference = now or datetime.now(timezone.utc)
if reference.tzinfo is None:
reference = reference.replace(tzinfo=timezone.utc)
return parsed > reference
def is_early_access_deadline_active(
ends_at: Any,
*,
now: Optional[datetime] = None,
) -> bool:
"""True when a legacy ``earlyAccessEndsAt`` deadline is still in the future.
Kept separate from :func:`is_gate_active` because the legacy field is a bare
timestamp rather than a DTO. A present-but-unparsable value is treated as active,
matching the previous conservative behaviour.
"""
if not ends_at:
return False
parsed = parse_civitai_timestamp(ends_at)
if parsed is None:
return True
reference = now or datetime.now(timezone.utc)
if reference.tzinfo is None:
reference = reference.replace(tzinfo=timezone.utc)
return parsed > reference
+65
View File
@@ -0,0 +1,65 @@
"""Resolution of the recipe library's storage directory.
Kept side-effect free (no directory creation): model scanners and file
services consult these helpers on hot paths, where silently materializing
the folder would be a surprise. ``RecipeScanner.recipes_dir`` remains the
write endpoint and adds the ``makedirs`` itself.
"""
from __future__ import annotations
import os
def get_effective_recipes_dir() -> str:
"""Resolve the directory recipe data lives in. May not exist on disk.
Mirrors ``RecipeScanner.recipes_dir`` minus the auto-create: the
``recipes_path`` setting wins; otherwise the default is ``recipes``
under the first configured lora root (``config.loras_roots`` is already
sorted case-insensitively). Returns ``""`` when no lora root exists.
"""
# Local imports: services -> config -> utils dependency direction must
# not pick up a utils -> services edge at module import time.
from ..services.settings_manager import get_settings_manager
from ..config import config
custom_recipes_dir = get_settings_manager().get("recipes_path", "")
if isinstance(custom_recipes_dir, str) and custom_recipes_dir.strip():
return os.path.abspath(
os.path.normpath(os.path.expanduser(custom_recipes_dir.strip()))
)
lora_roots = [
path
for path in (getattr(config, "loras_roots", None) or [])
if isinstance(path, str) and path.strip()
]
if not lora_roots:
return ""
return os.path.abspath(os.path.join(lora_roots[0], "recipes"))
def normalized_recipes_dir_key() -> str:
"""Comparison key (normcase + abspath) for the recipes dir; "" if unknown."""
try:
recipes_dir = get_effective_recipes_dir()
except Exception: # pragma: no cover - never break a scan over settings access
return ""
if not recipes_dir:
return ""
return os.path.normcase(os.path.abspath(recipes_dir))
def path_is_or_contains_recipes_dir(path: str) -> bool:
"""True when *path* is the recipes dir or one of its ancestors.
Deleting or renaming an ancestor wipes the recipe library just the same,
so folder operations must refuse both shapes.
"""
key = normalized_recipes_dir_key()
if not key:
return False
candidate = os.path.normcase(os.path.abspath(path))
return candidate == key or key.startswith(candidate + os.sep)
@@ -460,6 +460,20 @@
border-color: color-mix(in oklch, #845EF7 45%, transparent);
}
/* Price alert badge - green tone (#37B24D) for a price under the user's threshold */
.version-badge-price-alert {
background: color-mix(in oklch, #37B24D 25%, transparent);
color: #2B8A3E;
border-color: color-mix(in oklch, #37B24D 55%, transparent);
font-variant-numeric: tabular-nums;
}
[data-theme="dark"] .version-badge-price-alert {
background: color-mix(in oklch, #37B24D 20%, transparent);
color: #69DB7C;
border-color: color-mix(in oklch, #37B24D 45%, transparent);
}
.version-meta-ea {
color: #E67700;
font-weight: 600;
@@ -55,6 +55,10 @@
border-left-color: var(--color-warning);
}
.stat-card-offline {
border-left-color: var(--text-secondary);
}
.stat-card-total {
border-left-color: var(--lora-border);
}
@@ -42,6 +42,12 @@
animation: modalFadeIn 0.2s ease-out;
}
/* The label grows to "Delete N folders" when several roots are ticked; a fixed
floor keeps the centred action row from sliding sideways on every toggle. */
#deleteFolderModal .delete-btn {
min-width: 150px;
}
/* The folder delete confirm button is held disabled while the folder contents
are checked against the backend, so it must not look clickable. */
#deleteFolderModal .delete-btn:disabled {
@@ -100,3 +106,97 @@
text-align: left;
line-height: 1.5;
}
/* Root list for a folder that exists under several library roots: one checkbox
per copy, since "the folder" in a merged tree is all of them. Each row carries
its own guard verdict, so a copy that cannot be removed says why instead of
silently dropping out of the list. */
.delete-folder-roots:empty {
display: none;
}
.folder-root-picker {
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
margin: var(--space-2) 0;
text-align: left;
max-height: 200px;
overflow-y: auto;
}
.folder-root-picker-title {
color: var(--text-color);
font-size: 0.9em;
margin-bottom: var(--space-1, 4px);
}
.folder-root-option {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 0;
cursor: pointer;
line-height: 1.4;
}
.folder-root-option.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.folder-root-option input[type="checkbox"] {
margin-top: 2px;
flex: 0 0 auto;
}
/* Path over status, so a row keeps its height when the verdict replaces
"checking..." — the modal must not move while it is being read. */
.folder-root-body {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.folder-root-path {
font-family: var(--font-mono);
font-size: 0.85em;
word-break: break-all;
}
.folder-root-status {
font-size: 0.78em;
opacity: 0.75;
min-height: 1.2em;
line-height: 1.2;
}
.folder-root-status.blocked {
color: var(--lora-error);
opacity: 0.95;
}
/* Says what the unchecked copies mean. The line is always in the DOM with its
height reserved, so warning about them cannot resize the modal mid-click. */
.folder-root-kept-note {
min-height: 1.2em;
margin-top: 4px;
font-size: 0.78em;
line-height: 1.2;
opacity: 0.85;
color: var(--lora-warning, var(--lora-error));
}
/* Rename row: same choice, inline. Kept narrow so the row still reads as one
editable line in the sidebar tree. */
.sidebar-folder-root-select {
flex: 0 0 auto;
max-width: 45%;
font-size: 0.8em;
background: var(--lora-surface);
color: var(--text-color);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
+45
View File
@@ -700,3 +700,48 @@ body.sticky-controls .sticky-topbar {
padding: 10px 0;
}
}
/* Refresh dropdown: per-root scan scope (issue #1108). The list can hold one
entry per configured model root, so it scrolls instead of growing past the
viewport. Offline roots stay clickable (clicking explains the state) and are
only visually de-emphasised — unlike .dropdown-item.disabled, which swallows
the click via pointer-events: none. */
.dropdown-separator {
height: 1px;
margin: 5px 0;
background-color: var(--border-color);
}
.dropdown-section-title {
padding: 4px 15px 2px;
font-size: 0.85em;
font-weight: 600;
color: var(--text-color);
opacity: 0.6;
}
.dropdown-scope-list {
max-height: 45vh;
overflow-y: auto;
}
.scan-root-item.is-offline {
cursor: default;
opacity: 0.6;
}
.scan-root-label {
margin-right: 12px;
}
.scan-root-count,
.scan-root-offline {
float: right;
margin-left: 16px;
font-size: 0.9em;
opacity: 0.7;
}
.scan-root-item.is-offline .scan-root-offline {
opacity: 0.9;
}
+7
View File
@@ -86,6 +86,10 @@ export function getApiEndpoints(modelType) {
createFolder: `/api/lm/${modelType}/create-folder`,
deleteFolder: `/api/lm/${modelType}/delete-folder`,
renameFolder: `/api/lm/${modelType}/rename-folder`,
// 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
// own absolute path.
resolveFolder: `/api/lm/${modelType}/resolve-folder`,
// CivitAI integration
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
@@ -99,6 +103,9 @@ export function getApiEndpoints(modelType) {
ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`,
ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`,
// Price alerts are app-wide (all model types in one query), so they are
// not part of this per-type endpoint map; see UpdateService.js.
// Preview management
replacePreview: `/api/lm/${modelType}/replace-preview`,
setPreviewFromUrl: `/api/lm/${modelType}/set-preview-from-url`,
+137 -5
View File
@@ -511,8 +511,10 @@ export class BaseModelApiClient {
}
}
async refreshModels(fullRebuild = false) {
async refreshModels(fullRebuild = false, { roots = null, folder = null } = {}) {
const abortController = new AbortController();
const scopeRoots = Array.isArray(roots) ? roots.filter(Boolean) : [];
const scopeFolder = typeof folder === 'string' && folder ? folder : null;
const displayName = this.apiConfig.config.displayName;
const singularName = this.apiConfig.config.singularName;
const actionText = translate(
@@ -532,17 +534,48 @@ export class BaseModelApiClient {
);
const etaTracker = createScanEtaTracker();
let ws = null;
let lastProgressStage = null;
const handleScanProgress = (data) => {
if (typeof data.progress === 'number') {
state.loadingManager.setProgress(data.progress);
}
// The per-file rate of one stage says nothing about the next one:
// the walk phase counts files while the new-file pass processes
// them, so a carried-over average would produce a nonsense ETA.
if (data.stage && data.stage !== lastProgressStage) {
lastProgressStage = data.stage;
etaTracker.reset();
}
let statusText = translate(
`common.scanProgress.stages.${data.stage}`,
{ total: data.total },
data.stage || ''
);
if (data.status === 'processing' && data.total > 0) {
if (data.status === 'processing' && data.stage === 'reconcile_scan') {
// Walk phase: the real file count is only known once the walk
// finishes, so report the files checked so far and the roots
// being walked instead of a processed/total ratio.
const filesSeen = Number(data.files_seen) || 0;
if (filesSeen > 0) {
const roots = Array.isArray(data.active_roots)
? data.active_roots.filter(Boolean)
: [];
const formattedFiles = filesSeen.toLocaleString();
const filesText = translate(
'common.scanProgress.walkFiles',
{ count: formattedFiles },
`${formattedFiles} files`
);
statusText += roots.length
? ` ${roots.join(', ')} (${filesText})`
: ` (${filesText})`;
const etaText = etaTracker.update(data.processed, data.total);
if (etaText) {
statusText += ` | ${etaText}`;
}
}
} else if (data.status === 'processing' && data.total > 0) {
statusText += ` (${data.processed}/${data.total})`;
if (data.current_name) {
statusText += ` ${data.current_name}`;
@@ -569,6 +602,12 @@ export class BaseModelApiClient {
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
for (const root of scopeRoots) {
url.searchParams.append('roots', root);
}
if (scopeFolder) {
url.searchParams.append('folder', scopeFolder);
}
const response = await fetch(url, { signal: abortController.signal });
@@ -584,7 +623,7 @@ export class BaseModelApiClient {
resetAndReload(true);
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
this._showRefreshSummary(data, actionText, scopeRoots, scopeFolder);
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
@@ -601,6 +640,62 @@ export class BaseModelApiClient {
}
}
/**
* Report what a finished scan did.
*
* A scoped scan names the folder it scanned and how many models changed; a
* scan that could not read part of its scope says so instead of silently
* looking like "nothing found" (the entries are kept, not deleted).
* @param {Object} summary - Scan response payload
* @param {string} actionText - Localized "Refresh" / "Full rebuild"
* @param {Array<string>} scopeRoots - Roots the scan was restricted to
*/
_showRefreshSummary(summary, actionText, scopeRoots = [], scopeFolder = null) {
const payload = summary || {};
const scannedRoots = Array.isArray(payload.scanned_roots) ? payload.scanned_roots : [];
// A folder scan is named by the folder the user clicked; a root scan by
// the roots it walked.
const scopeLabel = payload.scope_label
|| (scannedRoots.length ? scannedRoots.join(', ') : '');
if ((scopeRoots.length || scopeFolder) && scopeLabel) {
showToast(
'toast.api.refreshCompleteScoped',
{
scope: scopeLabel,
added: Number(payload.added || 0),
removed: Number(payload.removed || 0),
},
'success'
);
} else {
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
}
const keptCount = Number(payload.kept_unreachable || 0);
if (keptCount > 0) {
const unavailable = Array.isArray(payload.unavailable_paths)
? payload.unavailable_paths
: [];
const skipped = Array.isArray(payload.skipped_roots) ? payload.skipped_roots : [];
// Skipped roots already carry a short label; the remaining
// unreadable folders only have a path.
const names = (
skipped.length
? skipped.map(entry => entry?.label || entry?.path)
: unavailable.map(entry => entry?.path)
).filter(Boolean);
showToast(
'toast.api.refreshKeptUnreachable',
{
count: keptCount.toLocaleString(),
paths: names.slice(0, 3).join(', ') || '—',
},
'info'
);
}
}
/**
* Connect to the shared fetch-progress WebSocket for scan progress updates.
* Returns null when the connection cannot be established (silent fallback).
@@ -803,7 +898,9 @@ export class BaseModelApiClient {
}
_showFailureDetailsModal(data) {
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds } = data;
const { failures = [], success, processed, total, failure_count, skipped_count, elapsed_seconds, offline_skipped = 0, offline_roots = [] } = data;
const offlineRootNames = offline_roots.map((root) => root.label || root.path || String(root)).join(', ');
// Build failure list HTML
const failureRows = failures.map((f, i) =>
@@ -840,6 +937,13 @@ export class BaseModelApiClient {
<span class="stat-card-value">${skipped_count}</span>
</div>
</div>
${offline_skipped > 0 ? `
<div class="stat-card stat-card-offline" title="${this._escapeHtml(offlineRootNames)}">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statOffline', {}, 'Drive Offline')}</span>
<span class="stat-card-value">${offline_skipped}</span>
</div>
</div>` : ''}
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.metadataFetchSummary.statTotal', {}, 'Total Scanned')}</span>
@@ -1206,7 +1310,7 @@ export class BaseModelApiClient {
if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) {
throw new Error(`This model is not a ${this.apiConfig.config.displayName}. Please switch to the appropriate page to download this model type.`);
}
throw new Error('Failed to fetch model versions');
throw new Error((errorData && errorData.error) || 'Failed to fetch model versions');
}
return await response.json();
} catch (error) {
@@ -1395,6 +1499,34 @@ export class BaseModelApiClient {
return result;
}
/**
* Resolve a library-relative folder onto the directories it stands for.
*
* The unified folder tree merges every model root into one relative-path
* namespace, so a tree node cannot be turned into a business path by
* prefixing a root: the same relative folder may live under several roots,
* or only under one that is not the default root. Callers that hold a node
* ask here first; the answer is what the folder APIs must be given.
*
* @param {string} folderPath Library-relative folder path (e.g. `a/b`)
* @returns {Promise<{folder: string, candidates: Array<{folder_path: string, root: string, is_symlink: boolean}>}>}
* `candidates` is empty when no root holds the directory on disk.
*/
async resolveFolder(folderPath) {
const params = new URLSearchParams({ folder: folderPath });
const response = await fetch(`${this.apiConfig.endpoints.resolveFolder}?${params}`);
const result = await response.json().catch(() => ({}));
if (!response.ok || result.success === false) {
const error = new Error(result.error || `Failed to resolve folder`);
error.code = result.code || null;
throw error;
}
return result;
}
async fetchUnifiedFolderTree(options = {}) {
try {
const { includeEmpty = false } = options;
File diff suppressed because it is too large Load Diff
@@ -33,9 +33,6 @@ export class CheckpointsControls extends PageControls {
return await resetAndReload(updateFolders);
},
refreshModels: async (fullRebuild = false) => {
return await getModelApiClient().refreshModels(fullRebuild);
},
// Add fetch from Civitai functionality for checkpoints
fetchFromCivitai: async () => {
@@ -30,9 +30,6 @@ export class EmbeddingsControls extends PageControls {
return await resetAndReload(updateFolders);
},
refreshModels: async (fullRebuild = false) => {
return await getModelApiClient().refreshModels(fullRebuild);
},
// Add fetch from Civitai functionality for embeddings
fetchFromCivitai: async () => {
@@ -37,9 +37,6 @@ export class LorasControls extends PageControls {
return await resetAndReload(updateFolders);
},
refreshModels: async (fullRebuild = false) => {
return await getModelApiClient().refreshModels(fullRebuild);
},
// LoRA-specific API functions
fetchFromCivitai: async () => {
@@ -31,9 +31,6 @@ export class OtherControls extends PageControls {
return await resetAndReload(updateFolders);
},
refreshModels: async (fullRebuild = false) => {
return await getModelApiClient().refreshModels(fullRebuild);
},
// Add fetch from Civitai functionality for other models
fetchFromCivitai: async () => {
+96 -3
View File
@@ -5,7 +5,9 @@ import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/u
import { eventManager } from '../../utils/EventManager.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
import { renderScanScopeMenu, resolveScanScopeTarget } from './ScanScopeMenu.js';
/**
* PageControls class - Unified control management for model pages
@@ -82,7 +84,18 @@ export class PageControls {
* @param {Object} api - API methods for the page
*/
registerAPI(api) {
this.api = api;
this.api = {
...api,
// These two are identical for every page and must stay in sync with
// the scan API: the refresh scope menu reads the root list, and a
// scoped refresh has to pass `roots` through. They are defined here
// rather than in each page facade so a page cannot silently drop
// them (which is exactly how the scope menu ended up empty).
fetchModelRoots: async () => getModelApiClient().fetchModelRoots(),
// Forward the exact arguments: an unscoped refresh must keep calling
// the client with a single argument.
refreshModels: async (...args) => getModelApiClient().refreshModels(...args),
};
console.log(`API methods registered for ${this.pageType} page`);
// Initialize sidebar manager after API is registered
@@ -233,7 +246,10 @@ export class PageControls {
});
// Toggle current dropdown
dropdownGroup.classList.toggle('active');
const isOpen = dropdownGroup.classList.toggle('active');
if (isOpen) {
this.loadScanScopeMenu();
}
});
});
@@ -248,6 +264,21 @@ export class PageControls {
});
}
// Per-root scan rows are rendered dynamically, so they are wired by
// delegation instead of a direct querySelector per item.
const scopeMenu = document.getElementById('refreshScopeMenu');
if (scopeMenu) {
scopeMenu.addEventListener('click', (e) => {
const item = e.target.closest('[data-action="scan-root"]');
if (!item) {
return;
}
e.stopPropagation();
this.handleScanRootSelection(item);
document.querySelector('.dropdown-group.active')?.classList.remove('active');
});
}
const checkUpdatesOption = document.getElementById('checkUpdatesMenuItem');
if (checkUpdatesOption) {
checkUpdatesOption.addEventListener('click', async (e) => {
@@ -266,6 +297,55 @@ export class PageControls {
});
}
/**
* Render the per-root scan entries of the refresh dropdown.
*
* Roots come from the backend (label, model count, reachability) so the menu
* always names a root exactly like the scan progress line does. The list is
* cached until the next scan finishes.
*/
async loadScanScopeMenu() {
const menu = document.getElementById('refreshScopeMenu');
if (!menu || typeof this.api?.fetchModelRoots !== 'function') {
return;
}
if (this._scanScopeDetails) {
this.renderScanScopeMenu(this._scanScopeDetails);
return;
}
if (this._scanScopeLoading) {
return;
}
this._scanScopeLoading = true;
try {
const payload = await this.api.fetchModelRoots();
const details = Array.isArray(payload?.root_details) ? payload.root_details : [];
this._scanScopeDetails = details;
renderScanScopeMenu(menu, details);
} catch (error) {
console.error('Error loading model roots:', error);
menu.innerHTML = '';
} finally {
this._scanScopeLoading = false;
}
}
handleScanRootSelection(item) {
const { rootPath, label, offline } = resolveScanScopeTarget(item);
if (!rootPath) {
return;
}
if (offline) {
// showToast(key, params, type): the sentence must NOT be passed here,
// it used to land in the `type` slot and produced an unstyled toast.
showToast('toast.api.scanRootUnreachable', { scope: label }, 'info');
return;
}
this.refreshModels(false, { roots: [rootPath] });
}
async handleCheckModelUpdates(menuItem) {
if (this._updateCheckInProgress) {
return;
@@ -482,14 +562,27 @@ export class PageControls {
* Refresh models list
* @param {boolean} fullRebuild - Whether to perform a full rebuild
*/
async refreshModels(fullRebuild = false) {
async refreshModels(fullRebuild = false, { roots = null, folder = null } = {}) {
if (!this.api) {
console.error('API methods not registered');
return;
}
// Root labels / reachability may have changed with the scan, and the
// model counts definitely did.
this._scanScopeDetails = null;
const scopedRoots = Array.isArray(roots) ? roots.filter(Boolean) : [];
const scopedFolder = typeof folder === 'string' && folder ? folder : null;
try {
if (scopedRoots.length || scopedFolder) {
await this.api.refreshModels(fullRebuild, {
...(scopedRoots.length ? { roots: scopedRoots } : {}),
...(scopedFolder ? { folder: scopedFolder } : {}),
});
} else {
await this.api.refreshModels(fullRebuild);
}
// Refresh sidebar after rebuild
if (this.sidebarManager) {
@@ -0,0 +1,72 @@
// ScanScopeMenu.js - per-root entries of the Refresh dropdown (issue #1108)
import { translate } from '../../utils/i18nHelpers.js';
/**
* Render one row per configured model root.
*
* Rows carry the root path in `data-root` and are wired by delegation, because
* the list is rebuilt every time the root set changes. An offline root stays
* clickable (the click explains why nothing can be scanned) and is only
* de-emphasised visually.
*
* @param {HTMLElement|null} menu - Container element (#refreshScopeMenu)
* @param {Array<Object>} details - `root_details` from GET /api/lm/{type}/roots
*/
export function renderScanScopeMenu(menu, details = []) {
if (!menu) {
return;
}
menu.innerHTML = '';
details.forEach(detail => {
if (!detail || !detail.path) {
return;
}
const item = document.createElement('div');
item.className = `dropdown-item scan-root-item${detail.reachable ? '' : ' is-offline'}`;
item.dataset.action = 'scan-root';
item.dataset.root = detail.path;
item.title = detail.path;
const icon = document.createElement('i');
icon.className = `fas ${detail.reachable ? 'fa-folder-open' : 'fa-plug-circle-xmark'}`;
item.appendChild(icon);
const label = document.createElement('span');
label.className = 'scan-root-label';
label.textContent = detail.label || detail.path;
item.appendChild(label);
const meta = document.createElement('span');
if (detail.reachable) {
const count = Number(detail.models || 0).toLocaleString();
meta.className = 'scan-root-count';
meta.textContent = translate(
'loras.controls.refresh.rootModels',
{ count },
`${count} models`
);
} else {
meta.className = 'scan-root-offline';
meta.textContent = translate('loras.controls.refresh.rootOffline', {}, 'Offline');
}
item.appendChild(meta);
menu.appendChild(item);
});
}
/**
* Read what a clicked row refers to.
* @param {HTMLElement} item - Row rendered by renderScanScopeMenu
* @returns {{rootPath: string, label: string, offline: boolean}}
*/
export function resolveScanScopeTarget(item) {
const rootPath = item?.dataset?.root || '';
const label = item?.querySelector?.('.scan-root-label')?.textContent || rootPath;
return {
rootPath,
label,
offline: Boolean(item?.classList?.contains('is-offline')),
};
}
@@ -187,6 +187,53 @@ function isPaidPermanent(version) {
return version && version.isPaid === true;
}
function isGated(version) {
return isEarlyAccessActive(version) || isPaidPermanent(version);
}
/**
* Format a Buzz price for display, or '' when the price is unknown (price
* tracking is opt-in, so most versions have no price at all).
*/
function formatBuzzPrice(value) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
return '';
}
return `${value.toLocaleString()} Buzz`;
}
/**
* Tooltip for a known price: notes a sale against the list price and the Blue
* Buzz option, since both change what the number means to a buyer.
*/
function buildPriceTooltip(version, baseTooltip) {
const effective = formatBuzzPrice(version?.priceBuzz);
const segments = [baseTooltip];
if (!effective) {
return baseTooltip;
}
const list = version?.listPriceBuzz;
if (typeof list === 'number' && Number.isFinite(list) && list > version.priceBuzz) {
segments.push(
translate(
'modals.model.versions.badges.priceSaleTooltip',
{ list: list.toLocaleString() },
`On sale, normally ${list.toLocaleString()} Buzz`
)
);
}
if (version?.acceptsBlueBuzz) {
segments.push(
translate(
'modals.model.versions.badges.priceBlueBuzzTooltip',
{},
'Can also be paid with Blue Buzz'
)
);
}
return segments.join(' · ');
}
function isDownloadAllowed(version) {
if (!version.usageControl) {
return true;
@@ -522,17 +569,45 @@ function renderRow(version, options) {
}));
}
if (isEarlyAccess) {
// Obtainability, only where there is a decision to make: a version already in
// the library cannot become cheaper *for this user*, so it gets no cost badge.
// The download price is the only price axis that matters here — the generation
// fee is what CivitAI charges to generate on its own site, which a local model
// never incurs.
const priceLabel = formatBuzzPrice(version.priceBuzz);
if (!version.isInLibrary && isEarlyAccess) {
// Early access is the one state with a real countdown: "free on <date>"
// decides between waiting and paying.
const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt);
badges.push(buildBadge(earlyAccessBadgeLabel, 'early-access', {
title: translate(
title: buildPriceTooltip(
version,
eaTime
? translate(
'modals.model.versions.badges.earlyAccessTooltipUntil',
{ date: eaTime },
`Early access until ${eaTime}`
)
: translate(
'modals.model.versions.badges.earlyAccessTooltip',
{},
'This version currently requires Civitai early access'
)
),
}));
}
if (isPaidPermanent(version)) {
if (!version.isInLibrary && isGated(version)) {
if (priceLabel) {
// The number already says "paid", so a separate Paid badge would be
// noise next to it.
badges.push(buildBadge(priceLabel, isEarlyAccess ? 'early-access' : 'paid', {
title: buildPriceTooltip(version, paidBadgeLabel),
}));
} else if (!isEarlyAccess) {
// Paid, but no readable price: the gate is certain (it comes from the
// public API), only the number is best-effort. Saying nothing here
// would read as "free", which is the one thing this version is not.
badges.push(buildBadge(paidBadgeLabel, 'paid', {
title: translate(
'modals.model.versions.badges.paidTooltip',
@@ -541,6 +616,20 @@ function renderRow(version, options) {
),
}));
}
}
// A version that used to be gated and no longer is. `gateLapsedAt` is
// persisted, so the marker survives long after the refresh that saw it.
if (!version.isInLibrary && !isGated(version) && version.gateLapsedAt) {
const freeBadgeLabel = translate('modals.model.versions.badges.freeNow', {}, 'Free Now');
badges.push(buildBadge(freeBadgeLabel, 'success', {
title: translate(
'modals.model.versions.badges.freeNowTooltip',
{ date: formatDateLabel(version.gateLapsedAt) || '' },
'This version no longer requires payment'
),
}));
}
if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
+11
View File
@@ -1169,6 +1169,17 @@ export class SettingsManager {
hidePaidUpdatesCheckbox.checked = state.global.settings.hide_paid_updates || false;
}
// Set buzz price tracking settings
const priceTrackingEnabledCheckbox = document.getElementById('priceTrackingEnabled');
if (priceTrackingEnabledCheckbox) {
priceTrackingEnabledCheckbox.checked = state.global.settings.price_tracking_enabled || false;
}
const priceCheckTtlInput = document.getElementById('priceCheckTtlHours');
if (priceCheckTtlInput) {
priceCheckTtlInput.value = state.global.settings.price_check_ttl_hours ?? 24;
}
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
+2
View File
@@ -58,6 +58,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
version_grouping: 'same_base',
hide_early_access_updates: false,
hide_paid_updates: false,
price_tracking_enabled: false,
price_check_ttl_hours: 24,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
skip_previously_downloaded_model_versions: false,
+15 -5
View File
@@ -228,12 +228,19 @@ export function extractCivitaiModelUrlParts(url) {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return { modelId: null, modelVersionId: null };
}
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
return { modelId: null, modelVersionId: null };
}
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
// Download URLs contain version IDs, not model IDs.
const pathMatch = parsedUrl.pathname.match(/^\/models\/(\d+)(?:\/|$)/);
if (!pathMatch) {
return { modelId: null, modelVersionId: null };
}
const modelId = pathMatch[1];
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
@@ -269,8 +276,11 @@ export function classifyModelRelinkUrl(url) {
}
const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, '');
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
const pathMatch = parsedUrl.pathname.match(/^\/models\/(\d+)(?:\/|$)/);
if (!pathMatch) {
return { source: null, modelId: null, modelVersionId: null };
}
const modelId = pathMatch[1];
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname)) {
@@ -294,7 +304,7 @@ export function extractCivitaiImageId(url) {
return null;
}
const pathMatch = parsedUrl.pathname.match(/\/images\/(\d+)/);
const pathMatch = parsedUrl.pathname.match(/^\/images\/(\d+)(?:\/|$)/);
return pathMatch ? pathMatch[1] : null;
} catch (e) {
return null;
+11 -1
View File
@@ -22,7 +22,7 @@ export function formatScanRemainingTime(remainingMs) {
* Create an ETA tracker for scan progress. Uses an exponential moving
* average (0.7/0.3) over the observed per-file processing time, mirroring
* the estimator in components/initialization.js.
* @returns {{ update: (processed: number, total: number) => (string|null) }}
* @returns {{ update: (processed: number, total: number) => (string|null), reset: () => void }}
*/
export function createScanEtaTracker() {
let startTime = null;
@@ -56,6 +56,16 @@ export function createScanEtaTracker() {
return translate('initialization.estimatingTime', {}, 'Estimating time...');
}
return formatScanRemainingTime((total - lastProcessed) * averageMsPerFile);
},
/**
* Drop all samples. Call when the scan moves to another stage: the
* per-file rate of one stage says nothing about the next one.
*/
reset() {
startTime = null;
lastProcessed = 0;
averageMsPerFile = null;
}
};
}
+6 -1
View File
@@ -139,9 +139,14 @@ export async function copyToClipboard(text, successMessage = null) {
* @param {string} type - Toast type (info/success/warning/error)
* @returns {HTMLElement} The toast element (not yet attached to the DOM)
*/
// Only these carry styling (icon + accent border). An unknown type is coerced
// to `info` instead of ending up as a bare `.toast` box — a wrong argument
// order at a call site used to leak the message itself into the class list.
const TOAST_TYPES = new Set(['info', 'success', 'warning', 'error', 'copy']);
function createToastElement(message, type) {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.className = `toast toast-${TOAST_TYPES.has(type) ? type : 'info'}`;
toast.textContent = message;
return toast;
}
+52
View File
@@ -8,6 +8,54 @@ import { modalManager } from '../managers/ModalManager.js';
const CHECK_UPDATES_CONFIRMATION_KEY = 'ack_check_updates_for_all_models';
/**
* Count the gate/price transitions reported by an update refresh.
* @param {Array} events - `events` array from the refresh response.
* @returns {{becameFree: number, newGate: number, priceDrop: number, total: number}}
*/
export function summarizeGateEvents(events) {
const list = Array.isArray(events) ? events : [];
return {
becameFree: list.filter((event) => event?.kind === 'became_free').length,
newGate: list.filter((event) => event?.kind === 'new_gate').length,
total: list.length
};
}
/**
* Toast the gate/price transitions of an update refresh, if any. Kept to a single
* toast so a large refresh cannot bury the user in notifications.
* @param {Array} events
*/
export function showGateEventsToast(events) {
const { becameFree, newGate } = summarizeGateEvents(events);
if (!becameFree && !newGate) {
return;
}
const parts = [];
if (becameFree) {
parts.push(
translate(
'globalContextMenu.checkModelUpdates.gateEvents.becameFree',
{ count: becameFree },
`${becameFree} version(s) became free`
)
);
}
if (newGate) {
parts.push(
translate(
'globalContextMenu.checkModelUpdates.gateEvents.newGate',
{ count: newGate },
`${newGate} version(s) are now paid`
)
);
}
showToast(parts.join(' · '), {}, becameFree ? 'success' : 'info');
}
/**
* Perform a model update check using the shared backend endpoint.
* @param {Object} [options]
@@ -85,6 +133,8 @@ export async function performModelUpdateCheck({ onStart, onComplete } = {}) {
showToast('globalContextMenu.checkModelUpdates.none', { type: displayName }, 'info');
}
showGateEventsToast(payload.events);
await resetAndReload(false);
} catch (err) {
if (err?.name === 'AbortError') {
@@ -180,6 +230,8 @@ export async function performFolderUpdateCheck(folderPath, { onComplete } = {})
showToast('sidebar.folderUpdateCheck.none', { type: displayName }, 'info');
}
showGateEventsToast(payload.events);
await resetAndReload(false);
} catch (err) {
if (err?.name === 'AbortError') {
+3
View File
@@ -210,6 +210,9 @@
dividers are collapsed by SidebarManager when a group is hidden on the
current page (recipes keep only the update check). -->
<div id="sidebarFolderContextMenu" class="context-menu">
<div class="context-menu-item" data-action="scan-folder">
<i class="fas fa-sync"></i> <span>{{ t('sidebar.scanFolder') }}</span>
</div>
<div class="context-menu-item" data-action="check-folder-updates">
<i class="fas fa-bell"></i> <span>{{ t('sidebar.folderUpdateCheck.label') }}</span>
</div>
+6
View File
@@ -73,6 +73,12 @@
<div class="dropdown-item" data-action="full-rebuild" title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}{% else %}{{ t('loras.controls.refresh.fullTooltip') }}{% endif %}">
<i class="fas fa-tools"></i> <span>{% if page_id == 'recipes' %}{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}{% else %}{{ t('loras.controls.refresh.full') }}{% endif %}</span>
</div>
{% if page_id != 'recipes' %}
<!-- Per-root scan scope, filled by PageControls.loadScanScopeMenu() -->
<div class="dropdown-separator"></div>
<div class="dropdown-section-title">{{ t('loras.controls.refresh.scopeSection') }}</div>
<div id="refreshScopeMenu" class="dropdown-scope-list"></div>
{% endif %}
</div>
</div>
@@ -112,13 +112,19 @@
</div>
<!-- Sidebar Folder Delete Confirmation Modal -->
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
subtree still holds models, so a cascade delete is refused). -->
<!-- Shared by five states: 'confirm' (model-free folder), 'blocked' (the
subtree still holds models, so a cascade delete is refused), 'busy' (a
staged delete is still pending), 'missing' (the directory is gone from
disk) and 'symlink' (every copy is a symbolic link). The roots container
holds the checkbox list shown when the same relative folder exists under
several library roots: each copy is guarded on its own and the ticked ones
are all deleted, since only the absolute paths tell those copies apart. -->
<div id="deleteFolderModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title">{{ t('sidebar.deleteFolderModal.title') }}</h2>
<p class="delete-message" data-role="message">{{ t('sidebar.deleteFolderModal.message') }}</p>
<div class="delete-model-info" data-role="info"></div>
<div class="delete-folder-roots" data-role="roots"></div>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-delete-folder">{{ t('common.actions.cancel') }}</button>
<button class="delete-btn" data-action="confirm-delete-folder">{{ t('sidebar.deleteFolderModal.confirm') }}</button>
@@ -192,6 +192,35 @@
{{ sm.setting_toggle('hidePaidUpdates', 'hide_paid_updates', 'settings.hidePaidUpdates.label', 'settings.hidePaidUpdates.help') }}
</div>
<!-- Buzz Price Tracking -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.priceTracking') }}
<div class="settings-help-text subtle">{{ t('settings.priceTracking.help') }}</div>
{{ sm.setting_toggle('priceTrackingEnabled', 'price_tracking_enabled', 'settings.priceTracking.enabled', 'settings.priceTracking.enabledHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="priceCheckTtlHours">
{{ t('settings.priceTracking.refreshHours') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priceTracking.refreshHoursHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input
type="number"
id="priceCheckTtlHours"
min="1"
step="1"
onblur="settingsManager.saveInputSetting('priceCheckTtlHours', 'price_check_ttl_hours')"
onkeydown="if(event.key === 'Enter') { this.blur(); }"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Example Images -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.exampleImages') }}
+132
View File
@@ -0,0 +1,132 @@
"""Wave 6: configured roots that are unavailable must be reportable and
re-admitted append-only, never dropped from the live lists."""
import os
import pytest
from py import config as config_module
def _make_config(**overrides) -> config_module.Config:
config = config_module.Config.__new__(config_module.Config)
config._path_mappings = {}
config._preview_root_paths = set()
config._cached_fingerprint = None
config.loras_roots = []
config.extra_loras_roots = []
config.base_models_roots = []
config.checkpoints_roots = []
config.unet_roots = []
config.embeddings_roots = []
config.extra_checkpoints_roots = []
config.extra_unet_roots = []
config.extra_embeddings_roots = []
config.other_roots = []
config.recipes_path = ""
config.other_root_subtypes = {}
config.other_folder_roots = {}
config._configured_root_paths = {}
for key, value in overrides.items():
setattr(config, key, value)
return config
def _normalize(path) -> str:
return os.path.normpath(str(path)).replace(os.sep, "/")
class TestAppendNewPaths:
def test_appends_without_reordering_or_duplicating(self):
merged = config_module._append_new_paths(
["/b/loras", "/a/loras"], ["/a/loras", "/c/loras", "", None]
)
# Existing order is preserved (it derives *_roots[0] consumers) and the
# new root lands at the end.
assert merged == ["/b/loras", "/a/loras", "/c/loras"]
def test_matches_case_insensitively(self):
merged = config_module._append_new_paths(["C:/Loras"], ["c:/loras"])
assert merged == ["C:/Loras"]
class TestAdmitConfiguredRoots:
def test_admits_a_root_that_became_readable(self, tmp_path):
primary = tmp_path / "primary"
primary.mkdir()
drive = tmp_path / "drive-Z" / "loras"
config = _make_config(
loras_roots=[str(primary)],
_configured_root_paths={"lora": [str(primary), str(drive)]},
)
assert config.configured_roots_for("lora") == [str(primary), str(drive)]
# Still switched off: nothing to admit.
assert config.admit_configured_roots() == []
assert config.loras_roots == [str(primary)]
drive.mkdir(parents=True)
admitted = config.admit_configured_roots()
assert admitted == [str(drive)]
assert config.loras_roots == [str(primary), str(drive)]
# Idempotent: a second call adds nothing.
assert config.admit_configured_roots() == []
def test_never_removes_a_root_that_disappeared(self, tmp_path):
primary = tmp_path / "primary"
primary.mkdir()
drive = tmp_path / "drive-Z" / "loras"
drive.mkdir(parents=True)
config = _make_config(
loras_roots=[str(primary), str(drive)],
_configured_root_paths={"lora": [str(drive), str(primary)]},
)
# The drive is switched off after the lists were built: the live list
# keeps it (reporting and pruning decide separately) and admission must
# not "clean it up" — a later settings save would persist the loss.
drive.rmdir()
config.admit_configured_roots()
assert config.loras_roots == [str(primary), str(drive)]
def test_admits_every_model_type_and_its_side_maps(self, tmp_path):
checkpoint_dir = tmp_path / "checkpoints"
unet_dir = tmp_path / "unet"
embedding_dir = tmp_path / "embeddings"
other_dir = tmp_path / "vae"
for directory in (checkpoint_dir, unet_dir, embedding_dir, other_dir):
directory.mkdir()
config = _make_config(
_configured_root_paths={
"checkpoint": [str(checkpoint_dir)],
"unet": [str(unet_dir)],
"embedding": [str(embedding_dir)],
"other": [str(other_dir)],
},
)
config._get_enabled_other_folder_keys = lambda: ["vae"]
config._configured_other_paths_for_key = lambda key: [str(other_dir)]
admitted = config.admit_configured_roots()
assert set(admitted) == {
_normalize(checkpoint_dir),
_normalize(unet_dir),
_normalize(embedding_dir),
_normalize(other_dir),
}
assert config.base_models_roots == [
_normalize(checkpoint_dir),
_normalize(unet_dir),
]
assert config.checkpoints_roots == [_normalize(checkpoint_dir)]
assert config.unet_roots == [_normalize(unet_dir)]
assert config.embeddings_roots == [_normalize(embedding_dir)]
assert config.other_roots == [_normalize(other_dir)]
assert config.other_root_subtypes[_normalize(other_dir)] == "vae"
assert config.other_folder_roots["vae"] == [_normalize(other_dir)]
+63
View File
@@ -905,3 +905,66 @@ def test_save_paths_removes_stale_empty_default_when_comfyui_exists(
assert name == "comfyui"
assert payload["activate"] is True
assert fake_settings.active_library == "comfyui"
def test_save_paths_keeps_configured_root_of_a_switched_off_drive(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
"""A root whose directory is missing right now must stay in the library.
`upsert_library(folder_paths=...)` replaces the stored paths, so persisting
the existence-filtered live list would erase a drive that happened to be
switched off when ComfyUI started. A path the host no longer configures must
still be dropped.
"""
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
readable = folder_paths["loras"][0]
switched_off = str(tmp_path / "drive-Z" / "loras")
removed_by_user = str(tmp_path / "old-location" / "loras")
# The host still configures the switched-off drive; it no longer configures
# the old location (that one only survives in the stored library paths).
folder_paths["loras"] = [switched_off, readable]
class FakeSettingsService:
active_library = "comfyui"
name: str = ""
payload: Dict[str, Any] = {}
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {
"loras": [removed_by_user, readable],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"default_lora_root": switched_off,
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_instance = config_module.Config()
persisted = fake_settings.payload["folder_paths"]["loras"]
# The switched-off drive keeps its configured path...
assert switched_off.replace("\\", "/") in persisted
assert readable.replace("\\", "/") in persisted
# ...the path the host really dropped is gone...
assert removed_by_user.replace("\\", "/") not in persisted
# ...and the live list still only holds what is readable right now.
assert config_instance.loras_roots == [readable.replace("\\", "/")]
# The user's default root survives because it is merely unavailable.
assert fake_settings.payload["default_lora_root"] == switched_off.replace("\\", "/")
+196 -2
View File
@@ -153,8 +153,8 @@ describe('BaseModelApiClient.refreshModels scan progress', () => {
};
}
async function startRefresh(client, fullRebuild = false) {
const promise = client.refreshModels(fullRebuild);
async function startRefresh(client, fullRebuild = false, options = {}) {
const promise = client.refreshModels(fullRebuild, options);
await vi.waitFor(() => {
expect(FakeWebSocket.instances.length).toBe(1);
});
@@ -304,6 +304,200 @@ describe('BaseModelApiClient.refreshModels scan progress', () => {
await promise;
});
it('shows the walked roots and file count during the reconcile walk', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise, socket } = await startRefresh(client);
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'reconcile_scan',
model_type: 'lora',
pageType: 'loras',
full_rebuild: false,
progress: 12,
processed: 1234,
total: 2000,
files_seen: 1234,
roots_total: 3,
roots_done: 0,
active_roots: ['G:', 'Y:'],
current_name: 'G:',
});
expect(setProgressMock).toHaveBeenCalledWith(12);
const walkStatus = setStatusMock.mock.calls.at(-1)[0];
expect(walkStatus).toContain('G:, Y:');
expect(walkStatus).toContain('1,234 files');
// No processed/total ratio: the real file count is unknown mid-walk.
expect(walkStatus).not.toContain('(1234/2000)');
expect(walkStatus).toContain('Estimating time...');
// Walk finished: no ETA once the counters meet the estimate.
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'reconcile_scan',
model_type: 'lora',
full_rebuild: false,
progress: 50,
processed: 2000,
total: 2000,
files_seen: 2000,
active_roots: [],
current_name: 'Y:',
});
const finalWalkStatus = setStatusMock.mock.calls.at(-1)[0];
expect(finalWalkStatus).toContain('2,000 files');
expect(finalWalkStatus).not.toContain('Estimating time...');
fetchControl.resolveOk();
await promise;
});
it('drops the ETA samples when the scan moves to another stage', async () => {
const fetchControl = mockFetchPending();
let now = 1000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const client = await createClient();
const { promise, socket } = await startRefresh(client);
const emit = (stage, processed, total, extra = {}) => socket.emit({
type: 'scan_progress',
status: 'processing',
stage,
model_type: 'lora',
full_rebuild: false,
progress: 50,
processed,
total,
...extra,
});
emit('process_models', 1, 10);
now = 101000;
emit('process_models', 2, 10);
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('~7 min remaining');
// Same counters on the walk stage: without the reset the old per-file rate
// (50s/file for 2 files) would be reused and produce a huge ETA.
now = 102000;
emit('reconcile_scan', 2, 100, { files_seen: 2, active_roots: ['G:'] });
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('Estimating time...');
fetchControl.resolveOk();
await promise;
});
it('requests a scoped scan when roots are passed', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'success' }),
});
const client = await createClient();
await client.refreshModels(false, { roots: ['/mnt/a/loras', '/mnt/b/loras'] });
const [url] = global.fetch.mock.calls[0];
expect(url.searchParams.getAll('roots')).toEqual(['/mnt/a/loras', '/mnt/b/loras']);
expect(url.searchParams.get('full_rebuild')).toBe('false');
});
it('reports the scoped scan summary and the entries kept unreachable', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise } = await startRefresh(client, false, { roots: ['/mnt/a/loras'] });
fetchControl.resolveOk({
status: 'success',
scanned_roots: ['a/loras'],
added: 2,
removed: 1,
kept_unreachable: 5,
unavailable_paths: [
{ path: '/mnt/g/loras', reason: 'root_unreachable', kept: 5 },
],
skipped_roots: [{ path: '/mnt/g/loras', label: 'g/loras' }],
});
await promise;
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshCompleteScoped',
{ scope: 'a/loras', added: 2, removed: 1 },
'success'
);
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshKeptUnreachable',
{ count: '5', paths: 'g/loras' },
'info'
);
});
it('requests a folder scan without a root', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'success' }),
});
const client = await createClient();
await client.refreshModels(false, { folder: 'pack/sub' });
const [url] = global.fetch.mock.calls[0];
// The backend walks this relative path under every root that holds it.
expect(url.searchParams.get('folder')).toBe('pack/sub');
expect(url.searchParams.getAll('roots')).toEqual([]);
});
it('names a folder scan by the folder, not by the roots it walked', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise } = await startRefresh(client, false, { folder: 'pack' });
fetchControl.resolveOk({
status: 'success',
scope_label: 'pack',
scanned_roots: ['a/loras', 'usb/loras'],
added: 3,
removed: 0,
kept_unreachable: 0,
});
await promise;
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshCompleteScoped',
{ scope: 'pack', added: 3, removed: 0 },
'success'
);
});
it('keeps the generic completion toast for a full-library scan', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise } = await startRefresh(client);
fetchControl.resolveOk({
status: 'success',
scanned_roots: ['a/loras', 'b/loras'],
added: 0,
removed: 0,
});
await promise;
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Refresh' },
'success'
);
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.api.refreshCompleteScoped',
expect.anything(),
expect.anything()
);
});
it('shows the cancelled toast when the server reports cancellation', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
const {
PAGE_CONTROLS_MODULE,
STATE_MODULE,
STORAGE_MODULE,
UI_HELPERS_MODULE,
EVENT_MANAGER_MODULE,
UPDATE_CHECK_MODULE,
SIDEBAR_MODULE,
SORT_DROPDOWN_MODULE,
API_FACTORY_MODULE,
I18N_MODULE,
} = vi.hoisted(() => ({
PAGE_CONTROLS_MODULE: new URL('../../../../static/js/components/controls/PageControls.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../../static/js/state/index.js', import.meta.url).pathname,
STORAGE_MODULE: new URL('../../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
EVENT_MANAGER_MODULE: new URL('../../../../static/js/utils/EventManager.js', import.meta.url).pathname,
UPDATE_CHECK_MODULE: new URL('../../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
SIDEBAR_MODULE: new URL('../../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
SORT_DROPDOWN_MODULE: new URL('../../../../static/js/components/controls/SortDropdown.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
const CONTROLS_TEMPLATE = path.resolve(
__dirname,
'../../../../templates/components/controls.html'
);
const showToastMock = vi.fn();
const modelClient = {
fetchModelRoots: vi.fn(),
refreshModels: vi.fn(),
};
vi.mock(STATE_MODULE, () => ({
state: {},
getCurrentPageState: vi.fn(() => ({ filters: {} })),
setCurrentPageType: vi.fn(),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: vi.fn(),
setStorageItem: vi.fn(),
removeStorageItem: vi.fn(),
getSessionItem: vi.fn(),
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
openCivitaiByMetadata: vi.fn(),
isTypingContext: vi.fn(() => false),
}));
vi.mock(EVENT_MANAGER_MODULE, () => ({
eventManager: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}));
vi.mock(UPDATE_CHECK_MODULE, () => ({
performModelUpdateCheck: vi.fn(),
}));
vi.mock(SIDEBAR_MODULE, () => ({
sidebarManager: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
},
}));
vi.mock(SORT_DROPDOWN_MODULE, () => ({
initSortDropdown: vi.fn(),
applySortToSelect: vi.fn(),
randomizeSortValue: vi.fn(),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: () => modelClient,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => {
if (!fallback) {
return key;
}
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
fallback
);
}),
}));
const { PageControls } = await import(PAGE_CONTROLS_MODULE);
const ROOT_DETAILS = [
{ path: '/mnt/a/loras', label: 'a/loras', reachable: true, models: 12 },
{ path: '/mnt/b/loras', label: 'b/loras', reachable: false, models: 3 },
];
function buildControlsDom() {
document.body.innerHTML = `
<div class="dropdown-group">
<button data-action="refresh" class="dropdown-main"></button>
<button class="dropdown-toggle"></button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="full-rebuild"></div>
<div class="dropdown-separator"></div>
<div class="dropdown-section-title">Scan one folder</div>
<div id="refreshScopeMenu" class="dropdown-scope-list"></div>
</div>
</div>`;
}
function bareControls(api = {}) {
const controls = Object.create(PageControls.prototype);
controls.pageType = 'loras';
controls.api = api;
controls._scanScopeDetails = null;
controls._scanScopeLoading = false;
controls.refreshModels = vi.fn();
controls.sidebarManager = {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
};
return controls;
}
describe('PageControls refresh scope menu', () => {
beforeEach(() => {
showToastMock.mockReset();
modelClient.fetchModelRoots.mockReset();
modelClient.refreshModels.mockReset();
modelClient.fetchModelRoots.mockResolvedValue({ root_details: ROOT_DETAILS });
});
afterEach(() => {
document.body.innerHTML = '';
});
it('keeps the scope container in the controls template (non-recipes pages)', () => {
const html = readFileSync(CONTROLS_TEMPLATE, 'utf-8');
const rebuildIndex = html.indexOf('data-action="full-rebuild"');
const scopeIndex = html.indexOf('id="refreshScopeMenu"');
expect(rebuildIndex).toBeGreaterThan(-1);
// Same dropdown, after the rebuild entry.
expect(scopeIndex).toBeGreaterThan(rebuildIndex);
expect(html).toContain('loras.controls.refresh.scopeSection');
// The recipes page has no model roots, so the section is gated.
expect(html.slice(rebuildIndex, scopeIndex)).toContain("{% if page_id != 'recipes' %}");
});
it('registers the scan-scope API for every page through registerAPI', async () => {
const controls = bareControls();
controls.registerAPI({});
expect(typeof controls.api.fetchModelRoots).toBe('function');
expect(typeof controls.api.refreshModels).toBe('function');
await controls.api.fetchModelRoots();
expect(modelClient.fetchModelRoots).toHaveBeenCalled();
// The page facades used to drop the second argument, which silently turned
// a scoped scan into a full refresh.
await controls.api.refreshModels(false, { roots: ['/mnt/a/loras'] });
expect(modelClient.refreshModels).toHaveBeenCalledWith(false, { roots: ['/mnt/a/loras'] });
});
it('renders the root rows when the dropdown is opened', async () => {
buildControlsDom();
const controls = bareControls({
fetchModelRoots: vi.fn(async () => ({ root_details: ROOT_DETAILS })),
refreshModels: vi.fn(),
});
controls.initDropdowns();
document.querySelector('.dropdown-toggle').click();
await vi.waitFor(() => {
expect(document.querySelectorAll('.scan-root-item').length).toBe(2);
});
expect(document.querySelector('.scan-root-item').dataset.root).toBe('/mnt/a/loras');
expect(document.querySelector('.scan-root-count').textContent).toBe('12 models');
expect(document.querySelectorAll('.scan-root-item')[1].classList.contains('is-offline')).toBe(true);
});
it('scans the clicked root only', async () => {
buildControlsDom();
const controls = bareControls({
fetchModelRoots: vi.fn(async () => ({ root_details: ROOT_DETAILS })),
refreshModels: vi.fn(),
});
controls.initDropdowns();
document.querySelector('.dropdown-toggle').click();
await vi.waitFor(() => {
expect(document.querySelectorAll('.scan-root-item').length).toBe(2);
});
document.querySelectorAll('.scan-root-item')[0].click();
expect(controls.refreshModels).toHaveBeenCalledWith(false, { roots: ['/mnt/a/loras'] });
expect(showToastMock).not.toHaveBeenCalled();
});
it('explains an offline root instead of scanning it', async () => {
buildControlsDom();
const controls = bareControls({
fetchModelRoots: vi.fn(async () => ({ root_details: ROOT_DETAILS })),
refreshModels: vi.fn(),
});
controls.initDropdowns();
document.querySelector('.dropdown-toggle').click();
await vi.waitFor(() => {
expect(document.querySelectorAll('.scan-root-item').length).toBe(2);
});
document.querySelectorAll('.scan-root-item')[1].click();
expect(controls.refreshModels).not.toHaveBeenCalled();
// Exactly three arguments: key, params, type. A 4th "fallback" argument
// pushed the sentence into the type slot and rendered an unstyled toast.
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.scanRootUnreachable',
{ scope: 'b/loras' },
'info'
);
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { I18N_MODULE, MODULE } = vi.hoisted(() => ({
I18N_MODULE: new URL('../../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
MODULE: new URL('../../../../static/js/components/controls/ScanScopeMenu.js', import.meta.url).pathname,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => {
if (!fallback) {
return key;
}
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
fallback
);
}),
}));
const { renderScanScopeMenu, resolveScanScopeTarget } = await import(MODULE);
describe('ScanScopeMenu', () => {
let menu;
beforeEach(() => {
document.body.innerHTML = '<div id="refreshScopeMenu"></div>';
menu = document.getElementById('refreshScopeMenu');
});
it('renders one row per root with label, count and offline state', () => {
renderScanScopeMenu(menu, [
{ path: '/mnt/a/loras', label: 'a/loras', reachable: true, models: 7 },
{ path: '/mnt/b/loras', label: 'b/loras', reachable: false, models: 0 },
]);
const rows = menu.querySelectorAll('.scan-root-item');
expect(rows.length).toBe(2);
expect(rows[0].dataset.action).toBe('scan-root');
expect(rows[0].dataset.root).toBe('/mnt/a/loras');
expect(rows[0].title).toBe('/mnt/a/loras');
expect(rows[0].querySelector('.scan-root-label').textContent).toBe('a/loras');
expect(rows[0].querySelector('.scan-root-count').textContent).toBe('7 models');
expect(rows[0].classList.contains('is-offline')).toBe(false);
expect(rows[1].classList.contains('is-offline')).toBe(true);
expect(rows[1].querySelector('.scan-root-offline').textContent).toBe('Offline');
// Offline rows stay clickable so the click can explain the state.
expect(rows[1].dataset.action).toBe('scan-root');
});
it('resolveScanScopeTarget reads path, label and offline state', () => {
renderScanScopeMenu(menu, [
{ path: '/mnt/b/loras', label: 'b/loras', reachable: false, models: 0 },
]);
expect(resolveScanScopeTarget(menu.querySelector('.scan-root-item'))).toEqual({
rootPath: '/mnt/b/loras',
label: 'b/loras',
offline: true,
});
});
it('skips malformed entries and replaces the previous list', () => {
renderScanScopeMenu(menu, [{ path: '/mnt/a' }, null, {}]);
expect(menu.querySelectorAll('.scan-root-item').length).toBe(1);
renderScanScopeMenu(menu, []);
expect(menu.querySelectorAll('.scan-root-item').length).toBe(0);
});
it('falls back to the path when a root has no label', () => {
renderScanScopeMenu(menu, [{ path: '/mnt/a/loras', reachable: true, models: 0 }]);
expect(menu.querySelector('.scan-root-label').textContent).toBe('/mnt/a/loras');
});
});
@@ -283,3 +283,116 @@ describe('ModelVersionsTab download button visibility', () => {
expect(openFileSelectionForVersion).not.toHaveBeenCalled();
});
});
describe('ModelVersionsTab obtainability badges', () => {
let getModelApiClient;
let fetchModelUpdateVersions;
beforeEach(async () => {
vi.resetModules();
document.body.innerHTML = `
<div id="model-versions-modal">
<div id="versions-tab">
<div class="model-versions-tab"></div>
</div>
</div>
`;
({ getModelApiClient } = await import(API_FACTORY_MODULE));
fetchModelUpdateVersions = vi.fn();
getModelApiClient.mockReturnValue({
fetchModelUpdateVersions,
fetchModelRoots: vi.fn(),
setModelUpdateIgnore: vi.fn(),
setVersionUpdateIgnore: vi.fn(),
deleteModel: vi.fn(),
});
});
afterEach(() => {
document.body.innerHTML = '';
});
function rowFor(versionId) {
return document.querySelector(`.model-version-row[data-version-id="${versionId}"]`);
}
function renderOne(version) {
fetchModelUpdateVersions.mockResolvedValue(buildRecord([
{
versionId: 1,
name: 'v1',
isInLibrary: false,
shouldIgnore: false,
...version,
},
]));
return renderVersions();
}
const PAID_GATE = { permanent: true, endsAt: null };
it('shows the download price for a paid version the user does not have', async () => {
await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: 5000 });
expect(rowFor(1).textContent).toContain('5,000 Buzz');
});
it('says Paid without a number when the price could not be read', async () => {
// The gate is certain (it comes from the public API); only the number is
// best-effort, and that is our plumbing rather than the user's problem.
await renderOne({ isPaid: true, paidAccess: PAID_GATE, priceBuzz: null });
expect(rowFor(1).textContent).toContain('Paid');
expect(rowFor(1).textContent).not.toContain('Buzz');
});
it('shows the early access countdown alongside the price', async () => {
await renderOne({
isEarlyAccess: true,
earlyAccessEndsAt: new Date(Date.now() + 3 * 86400000).toISOString(),
paidAccess: { permanent: false, endsAt: '2999-01-01T00:00:00.000Z' },
priceBuzz: 500,
});
const text = rowFor(1).textContent;
expect(text).toContain('Early Access');
expect(text).toContain('500 Buzz');
});
it('shows nothing about cost for a version already in the library', async () => {
// A version on disk cannot become cheaper *for this user*.
await renderOne({
isInLibrary: true,
isPaid: true,
paidAccess: PAID_GATE,
priceBuzz: 5000,
gateLapsedAt: '2026-09-28T00:00:00.000Z',
});
const text = rowFor(1).textContent;
expect(text).not.toContain('Buzz');
expect(text).not.toContain('Paid');
expect(text).not.toContain('Free Now');
expect(text).toContain('In Library');
});
it('marks a version that became free and is not in the library', async () => {
await renderOne({
isInLibrary: false,
isPaid: false,
priceBuzz: null,
gateLapsedAt: '2026-09-28T00:00:00.000Z',
});
expect(rowFor(1).textContent).toContain('Free Now');
});
it('shows nothing at all for a plain free version', async () => {
await renderOne({ isInLibrary: false, isPaid: false, priceBuzz: null });
const text = rowFor(1).textContent;
expect(text).not.toContain('Buzz');
expect(text).not.toContain('Paid');
expect(text).not.toContain('Free Now');
});
});
@@ -50,6 +50,17 @@ function createApiClient(overrides = {}) {
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ tree: { full: {}, empty: {} } }),
fetchModelFolders: vi.fn().mockResolvedValue({ folders: ['', 'full'] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
// Mirrors the backend resolver: a single-root library answers with the one
// directory the relative folder maps to.
resolveFolder: vi.fn((folder) => Promise.resolve({
success: true,
folder,
candidates: [{
folder_path: `/models/loras/${folder}`,
root: '/models/loras',
is_symlink: false,
}],
})),
createFolder: vi.fn().mockResolvedValue({ success: true, folder: 'new-folder', created: true }),
deleteFolder: vi.fn().mockResolvedValue({
success: true,
@@ -451,6 +462,33 @@ describe('SidebarManager folder creation', () => {
expect(manager.refresh).toHaveBeenCalledTimes(1);
});
it('creates inside the parent root even when it is not the default root', async () => {
const apiClient = createApiClient({
fetchModelRoots: vi.fn().mockResolvedValue({
roots: ['/models/loras', '/models/extra'],
}),
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'recipes',
candidates: [{
folder_path: '/models/loras/recipes',
root: '/models/loras',
is_symlink: false,
}],
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
state.global.settings = { default_lora_root: '/models/extra' };
const success = await manager._createFolder('recipes/presets', 'recipes');
// A new folder belongs next to the node it was created from; only a
// root-level creation falls back to the configured default root.
expect(success).toBe(true);
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/recipes/presets');
});
it('re-enables empty folders when creating while the preference is off', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
@@ -613,6 +651,7 @@ describe('SidebarManager folder deletion', () => {
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<div class="delete-model-info" data-role="info"></div>
<div class="delete-folder-roots" data-role="roots"></div>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-delete-folder">Cancel</button>
<button class="delete-btn" data-action="confirm-delete-folder">Delete folder</button>
@@ -624,6 +663,14 @@ describe('SidebarManager folder deletion', () => {
return document.querySelector('#deleteFolderModal [data-action="confirm-delete-folder"]');
}
function rootChoices() {
return [...document.querySelectorAll('#deleteFolderModal input[name="deleteFolderRoot"]')];
}
function keptNote() {
return document.querySelector('#deleteFolderModal .folder-root-kept-note');
}
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = MODAL_HTML;
@@ -642,10 +689,14 @@ describe('SidebarManager folder deletion', () => {
expect(modal.dataset.state).toBe('confirm');
expect(confirmBtn().style.display).toBe('');
expect(confirmBtn().disabled).toBe(false);
expect(manager._pendingDeleteFolderPath).toBe('empty');
// The pending target is the resolved directory, never the bare tree path:
// the tree path is relative to a root this node may not even live under.
expect(manager._pendingDeleteFolderRelative).toBe('empty');
expect(manager._pendingDeleteFolderPath).toBe('/models/loras/empty');
expect(modalManager.showModal).toHaveBeenCalledWith('deleteFolderModal');
// The prediction is confirmed against the real guard before the user can
// act on it.
expect(apiClient.resolveFolder).toHaveBeenCalledWith('empty');
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
'/models/loras/empty', { dryRun: true }
);
@@ -743,10 +794,25 @@ describe('SidebarManager folder deletion', () => {
expect(confirmBtn().style.display).toBe('none');
});
it('blocks the delete when the folder holds the recipe library', async () => {
const protectedError = Object.assign(new Error('recipe library'), { code: 'protected' });
const apiClient = createApiClient({
deleteFolder: vi.fn().mockRejectedValue(protectedError),
});
const manager = createManager(apiClient);
manager.nonEmptyFolders = new Set(['', 'full']);
await manager.showDeleteFolderModal('empty');
const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('protected');
expect(confirmBtn().style.display).toBe('none');
});
it('keeps the confirm button disabled until the check settles', async () => {
let release;
const apiClient = createApiClient({
fetchModelRoots: vi.fn(() => new Promise((resolve) => { release = resolve; })),
resolveFolder: vi.fn(() => new Promise((resolve) => { release = resolve; })),
});
const manager = createManager(apiClient);
manager.nonEmptyFolders = new Set(['', 'full']);
@@ -754,7 +820,15 @@ describe('SidebarManager folder deletion', () => {
const pending = manager.showDeleteFolderModal('empty');
expect(confirmBtn().disabled).toBe(true);
release({ roots: ['/models/loras'] });
release({
success: true,
folder: 'empty',
candidates: [{
folder_path: '/models/loras/empty',
root: '/models/loras',
is_symlink: false,
}],
});
await pending;
expect(confirmBtn().disabled).toBe(false);
@@ -920,13 +994,386 @@ describe('SidebarManager folder deletion', () => {
it('routes the modal buttons to cancel and confirm', () => {
const manager = createManager(createApiClient());
manager._deleteFolder = vi.fn().mockResolvedValue(true);
manager._pendingDeleteFolderPath = 'empty';
manager._pendingDeleteFolderRelative = 'empty';
manager._pendingDeleteFolderPath = '/models/loras/empty';
manager._wireDeleteFolderModal();
confirmBtn().dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(modalManager.closeModal).toHaveBeenCalledWith('deleteFolderModal');
expect(manager._deleteFolder).toHaveBeenCalledWith('empty');
expect(manager._deleteFolder).toHaveBeenCalledWith('empty', '/models/loras/empty');
});
it('deletes the directory the node actually lives in, not the default root one', async () => {
// The reported bug: the node exists under the primary root while
// default_lora_root points at the extra root, so prefixing the default root
// produced a path that did not exist and the delete always failed.
const apiClient = createApiClient({
fetchModelRoots: vi.fn().mockResolvedValue({
roots: ['/models/loras', '/models/extra'],
}),
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'recipes',
candidates: [{
folder_path: '/models/loras/recipes',
root: '/models/loras',
is_symlink: false,
}],
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
state.global.settings = { default_lora_root: '/models/extra' };
await manager.showDeleteFolderModal('recipes');
const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('confirm');
expect(apiClient.deleteFolder).toHaveBeenCalledWith(
'/models/loras/recipes', { dryRun: true }
);
// The absolute path is shown, so the modal names the directory it targets.
expect(modal.querySelector('[data-role="info"]').textContent)
.toContain('/models/loras/recipes');
await manager.handleDeleteFolderConfirm();
expect(apiClient.deleteFolder).toHaveBeenLastCalledWith('/models/loras/recipes');
});
it('lists every root holding the folder as a checked row, and deletes only the ticked ones', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'Pony',
candidates: [
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
],
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
state.global.settings = { default_lora_root: '/models/extra' };
await manager.showDeleteFolderModal('Pony');
const modal = document.getElementById('deleteFolderModal');
const choices = rootChoices();
expect(choices.map((input) => input.value)).toEqual([
'/models/extra/Pony',
'/models/loras/Pony',
]);
// Both copies are selected up front: the node stands for all of them, and
// deleting it one root at a time was the complaint.
expect(choices.map((input) => input.checked)).toEqual([true, true]);
expect(choices.every((input) => input.type === 'checkbox')).toBe(true);
expect(choices.every((input) => input.disabled === false)).toBe(true);
expect(confirmBtn().textContent).toContain('2');
// Each copy carries its own verdict instead of one shared claim.
expect(modal.querySelectorAll('.folder-root-status')).toHaveLength(2);
expect([...modal.querySelectorAll('.folder-root-status')].map((el) => el.textContent))
.toEqual(['no models', 'no models']);
// The absolute paths live on the rows, so the header does not single one out.
expect(modal.querySelector('[data-role="info"]').textContent).not.toContain('/models/');
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/extra/Pony', { dryRun: true });
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/Pony', { dryRun: true });
// Unticking one copy keeps the other, and does not rebuild the list.
const firstRow = choices[0];
const secondRow = choices[1];
expect(keptNote().textContent).toBe('');
choices[1].checked = false;
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
expect(rootChoices()[0]).toBe(firstRow);
expect(rootChoices()[1]).toBe(secondRow);
expect(rootChoices().map((input) => input.checked)).toEqual([true, false]);
expect(confirmBtn().textContent).not.toContain('2');
// The node stays in the sidebar while another root holds the folder, so the
// modal says so before the user commits.
expect(keptNote().textContent).toBe('Unchecked copies keep this folder in the sidebar.');
// Unticking everything just disables the button: the user emptied the
// selection, the folders are not "blocked".
choices[0].checked = false;
choices[0].dispatchEvent(new Event('change', { bubbles: true }));
expect(confirmBtn().disabled).toBe(true);
expect(confirmBtn().style.display).toBe('');
expect(modal.querySelector('[data-role="title"]').textContent).toBe('Delete folder?');
choices[0].checked = true;
choices[0].dispatchEvent(new Event('change', { bubbles: true }));
expect(confirmBtn().disabled).toBe(false);
// Re-ticking the copy clears the warning again...
choices[1].checked = true;
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
expect(keptNote().textContent).toBe('');
// ... and the copy is left alone for the rest of the test.
choices[1].checked = false;
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
await manager.handleDeleteFolderConfirm();
// Real deletes: only the ticked copy, and both dry runs are distinct calls.
expect(apiClient.deleteFolder).toHaveBeenLastCalledWith('/models/extra/Pony');
expect(apiClient.deleteFolder).not.toHaveBeenCalledWith('/models/loras/Pony');
expect(manager.refresh).toHaveBeenCalledTimes(1);
// A single removed copy keeps the singular wording (no "from 1 roots"), but
// the toast stays a plain success: the sidebar node's fate is disk truth.
expect(showActionToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.success',
{ name: 'Pony' },
'success',
expect.any(Object)
);
});
it('restores only the deleted copy when a kept copy survives the undo', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'test',
candidates: [
{ folder_path: '/models/loras/test', root: '/models/loras', is_symlink: false },
{ folder_path: '/models/extra/test', root: '/models/extra', is_symlink: false },
],
}),
deleteFolder: vi.fn().mockResolvedValue({ success: true, folder: 'test', restorable: true }),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager.showDeleteFolderModal('test');
const choices = rootChoices();
choices[1].checked = false;
choices[1].dispatchEvent(new Event('change', { bubbles: true }));
await manager.handleDeleteFolderConfirm();
const undo = showActionToast.mock.calls[0][3].onAction;
await undo();
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/test');
expect(apiClient.createFolder).not.toHaveBeenCalledWith('/models/extra/test');
});
it('unchecks and explains a copy that still holds models', async () => {
const conflict = Object.assign(new Error('still contains models'), {
code: 'not_empty',
manifest: { model_count: 2, excluded_model_count: 0 },
});
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'Pony',
candidates: [
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
],
}),
deleteFolder: vi.fn((path, options) => (
path === '/models/loras/Pony' && options?.dryRun
? Promise.reject(conflict)
: Promise.resolve({ success: true, folder: 'Pony', restorable: true })
)),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager.showDeleteFolderModal('Pony');
const choices = rootChoices();
expect(choices.map((input) => input.checked)).toEqual([true, false]);
expect(choices[1].disabled).toBe(true);
const statuses = [...document.querySelectorAll('#deleteFolderModal .folder-root-status')];
expect(statuses[0].textContent).toBe('no models');
expect(statuses[1].textContent).toContain('2 model file(s)');
expect(statuses[1].classList.contains('blocked')).toBe(true);
expect(confirmBtn().disabled).toBe(false);
await manager.handleDeleteFolderConfirm();
// The clean copy is deleted; the blocked one is never asked for a real delete.
expect(apiClient.deleteFolder).toHaveBeenLastCalledWith('/models/extra/Pony');
expect(apiClient.deleteFolder).not.toHaveBeenCalledWith('/models/loras/Pony');
});
it('blocks the whole modal when no copy can be deleted', async () => {
const conflict = Object.assign(new Error('still contains models'), {
code: 'not_empty',
manifest: { model_count: 1, excluded_model_count: 0 },
});
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'Pony',
candidates: [
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
],
}),
deleteFolder: vi.fn().mockRejectedValue(conflict),
});
const manager = createManager(apiClient);
await manager.showDeleteFolderModal('Pony');
const modal = document.getElementById('deleteFolderModal');
expect(confirmBtn().style.display).toBe('none');
expect(modal.querySelector('[data-role="title"]').textContent).toBe('Folder is not empty');
expect(rootChoices().every((input) => input.disabled)).toBe(true);
});
it('deletes every ticked copy in one action and restores them all on undo', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'test',
candidates: [
{ folder_path: '/models/loras/test', root: '/models/loras', is_symlink: false },
{ folder_path: '/models/extra/test', root: '/models/extra', is_symlink: false },
],
}),
deleteFolder: vi.fn().mockResolvedValue({ success: true, folder: 'test', restorable: true }),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager.showDeleteFolderModal('test');
await manager.handleDeleteFolderConfirm();
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/test');
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/extra/test');
expect(showActionToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.successMulti',
{ name: 'test', count: 2 },
'success',
expect.any(Object)
);
const undo = showActionToast.mock.calls[0][3].onAction;
await undo();
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/loras/test');
expect(apiClient.createFolder).toHaveBeenCalledWith('/models/extra/test');
});
it('reports the copies it managed to delete when one of them fails', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'test',
candidates: [
{ folder_path: '/models/loras/test', root: '/models/loras', is_symlink: false },
{ folder_path: '/models/extra/test', root: '/models/extra', is_symlink: false },
],
}),
deleteFolder: vi.fn((path, options) => {
if (options?.dryRun) return Promise.resolve({ success: true });
if (path === '/models/extra/test') {
return Promise.reject(Object.assign(new Error('gone'), { code: 'missing' }));
}
return Promise.resolve({ success: true, folder: 'test', restorable: true });
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
await manager.showDeleteFolderModal('test');
const success = await manager.handleDeleteFolderConfirm();
expect(success).toBe(true);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.partial',
{ count: 1, total: 2, failed: 1 },
'warning'
);
expect(manager.refresh).toHaveBeenCalledTimes(1);
});
it('reports a stale tree node as missing instead of fabricating a path', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'removed', candidates: [],
}),
});
const manager = createManager(apiClient);
manager.nonEmptyFolders = new Set(['', 'full']);
await manager.showDeleteFolderModal('removed');
const modal = document.getElementById('deleteFolderModal');
expect(modal.dataset.state).toBe('missing');
expect(confirmBtn().style.display).toBe('none');
expect(manager._pendingDeleteFolderPath).toBeNull();
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
});
it('refuses a node whose every copy is a symbolic link', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'linked',
candidates: [
{ folder_path: '/models/loras/linked', root: '/models/loras', is_symlink: true },
],
}),
});
const manager = createManager(apiClient);
await manager.showDeleteFolderModal('linked');
expect(document.getElementById('deleteFolderModal').dataset.state).toBe('symlink');
expect(confirmBtn().style.display).toBe('none');
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
});
it('does not guess a root when the resolver fails on a multi-root library', async () => {
const apiClient = createApiClient({
fetchModelRoots: vi.fn().mockResolvedValue({
roots: ['/models/loras', '/models/extra'],
}),
resolveFolder: vi.fn().mockRejectedValue(new Error('boom')),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._deleteFolder('Pony');
expect(success).toBe(false);
expect(apiClient.deleteFolder).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith('sidebar.folderResult.unresolved', {}, 'error');
});
it('keeps the single-root fallback for clients without the resolver', async () => {
const apiClient = createApiClient({ resolveFolder: undefined });
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._deleteFolder('empty');
expect(success).toBe(true);
expect(apiClient.deleteFolder).toHaveBeenCalledWith('/models/loras/empty');
});
it('reports a folder that vanished between the check and the confirm', async () => {
const apiClient = createApiClient({
deleteFolder: vi.fn().mockRejectedValue(
Object.assign(new Error('Folder no longer exists'), { code: 'missing' })
),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._deleteFolder('empty');
expect(success).toBe(false);
expect(showToast).toHaveBeenCalledWith(
'sidebar.deleteFolderResult.missing', {}, 'warning'
);
});
it('routes the context-menu action to the delete modal', () => {
@@ -969,12 +1416,12 @@ describe('SidebarManager folder rename', () => {
return document.querySelector('#sidebarRenameFolderInput .sidebar-rename-folder-input');
}
it('turns the node into a prefilled inline row in tree mode', () => {
it('turns the node into a prefilled inline row in tree mode', async () => {
const manager = createManager(createApiClient());
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
await manager.showRenameFolderInput('characters/anime');
const row = document.getElementById('sidebarRenameFolderInput');
expect(row).not.toBeNull();
@@ -986,12 +1433,12 @@ describe('SidebarManager folder rename', () => {
expect(manager._renameFolderPath).toBe('characters/anime');
});
it('inserts the row in place in list mode', () => {
it('inserts the row in place in list mode', async () => {
const manager = createManager(createApiClient(), { displayMode: 'list' });
manager.foldersList = ['characters', 'characters/anime'];
manager.renderFolderList();
manager.showRenameFolderInput('characters/anime');
await manager.showRenameFolderInput('characters/anime');
const row = document.getElementById('sidebarRenameFolderInput');
expect(row.querySelector('.sidebar-node-content')).not.toBeNull();
@@ -999,12 +1446,12 @@ describe('SidebarManager folder rename', () => {
expect(row.nextElementSibling).toBe(item);
});
it('restores the node when the edit is canceled', () => {
it('restores the node when the edit is canceled', async () => {
const manager = createManager(createApiClient());
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
await manager.showRenameFolderInput('characters/anime');
manager.handleRenameFolderCancel();
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
@@ -1054,7 +1501,7 @@ describe('SidebarManager folder rename', () => {
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
await manager.showRenameFolderInput('characters/anime');
renameInput().value = 'anime';
await manager.handleRenameFolderSubmit();
@@ -1068,7 +1515,7 @@ describe('SidebarManager folder rename', () => {
manager.treeData = { characters: { anime: {} } };
manager.renderTree();
manager.showRenameFolderInput('characters/anime');
await manager.showRenameFolderInput('characters/anime');
renameInput().value = 'bad/name';
await manager.handleRenameFolderSubmit();
@@ -1102,6 +1549,97 @@ describe('SidebarManager folder rename', () => {
expect(manager.showRenameFolderInput).toHaveBeenCalledWith('characters/anime');
});
it('renames the directory the node actually lives in', async () => {
// Same trap as the delete flow: the node is under the primary root while
// the configured default root is another one.
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'recipes',
candidates: [{
folder_path: '/models/loras/recipes',
root: '/models/loras',
is_symlink: false,
}],
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
state.global.settings = { default_lora_root: '/models/extra' };
const success = await manager._renameFolder('recipes', 'presets');
expect(success).toBe(true);
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/recipes', 'presets');
});
it('lets the user pick the copy to rename when several roots hold the folder', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'Pony',
candidates: [
{ folder_path: '/models/extra/Pony', root: '/models/extra', is_symlink: false },
{ folder_path: '/models/loras/Pony', root: '/models/loras', is_symlink: false },
],
}),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
state.global.settings = { default_lora_root: '/models/extra' };
manager.treeData = { Pony: {} };
manager.renderTree();
await manager.showRenameFolderInput('Pony');
const picker = document.querySelector('#sidebarRenameFolderInput .sidebar-folder-root-select');
expect([...picker.options].map((option) => option.value)).toEqual([
'/models/extra/Pony',
'/models/loras/Pony',
]);
// The default root's copy is preselected, the other one stays reachable.
expect(picker.value).toBe('/models/extra/Pony');
picker.value = '/models/loras/Pony';
renameInput().value = 'PonyV6';
await manager.handleRenameFolderSubmit();
expect(apiClient.renameFolder).toHaveBeenCalledWith('/models/loras/Pony', 'PonyV6');
});
it('reports a stale node instead of opening the rename row', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({
success: true, folder: 'gone', candidates: [],
}),
});
const manager = createManager(apiClient);
manager.treeData = { gone: {} };
manager.renderTree();
await manager.showRenameFolderInput('gone');
expect(document.getElementById('sidebarRenameFolderInput')).toBeNull();
expect(showToast).toHaveBeenCalledWith('sidebar.renameFolderResult.missing', {}, 'warning');
});
it('does not guess a root when the resolver fails on a multi-root library', async () => {
const apiClient = createApiClient({
fetchModelRoots: vi.fn().mockResolvedValue({
roots: ['/models/loras', '/models/extra'],
}),
resolveFolder: vi.fn().mockRejectedValue(new Error('boom')),
});
const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined);
const success = await manager._renameFolder('Pony', 'PonyV6');
expect(success).toBe(false);
expect(apiClient.renameFolder).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith('sidebar.folderResult.unresolved', {}, 'error');
});
it('hides the rename entry when folder management is unsupported', () => {
document.body.insertAdjacentHTML('beforeend', `
<div id="sidebarFolderContextMenu" class="context-menu">
@@ -0,0 +1,142 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const {
SIDEBAR_MANAGER_MODULE,
STORAGE_HELPERS_MODULE,
MODEL_API_FACTORY_MODULE,
I18N_MODULE,
BULK_MANAGER_MODULE,
UI_HELPERS_MODULE,
UPDATE_CHECK_MODULE,
STATE_MODULE,
MODAL_MANAGER_MODULE,
} = vi.hoisted(() => ({
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
}));
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), showActionToast: vi.fn() }));
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
const { showToast } = await import(UI_HELPERS_MODULE);
function createApiClient(overrides = {}) {
return {
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
supportsMove: true,
supportsFolderManagement: true,
},
},
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras', '/mnt/usb/loras'] }),
resolveFolder: vi.fn().mockResolvedValue({
success: true,
folder: 'pack',
candidates: [
{ folder_path: '/models/loras/pack', root: '/models/loras', is_symlink: false },
{ folder_path: '/mnt/usb/loras/pack', root: '/mnt/usb/loras', is_symlink: false },
],
}),
...overrides,
};
}
function createManager(apiClient) {
const manager = new SidebarManager();
manager.pageType = 'loras';
manager.apiClient = apiClient;
manager.renderFolderDisplay = vi.fn();
manager.renderEmptyState = vi.fn();
return manager;
}
describe('SidebarManager folder scan', () => {
beforeEach(() => {
localStorage.clear();
document.body.innerHTML = '';
showToast.mockClear();
});
it('scans the relative folder instead of guessing a root', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
const refreshModels = vi.fn().mockResolvedValue(undefined);
manager.pageControls = { refreshModels };
await manager.scanFolder('pack');
// The backend walks this relative path under every root that holds it, so
// the request carries no root at all.
expect(refreshModels).toHaveBeenCalledWith(false, { folder: 'pack' });
expect(showToast).not.toHaveBeenCalled();
});
it('falls back to the registered page controls after a re-init', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.pageControls = null;
manager.lastPageControls = { refreshModels: vi.fn().mockResolvedValue(undefined) };
await manager.scanFolder('pack');
expect(manager.lastPageControls.refreshModels).toHaveBeenCalledWith(false, { folder: 'pack' });
});
it('explains a folder that no root holds any more', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockResolvedValue({ success: true, folder: 'gone', candidates: [] }),
});
const manager = createManager(apiClient);
const refreshModels = vi.fn();
manager.pageControls = { refreshModels };
await manager.scanFolder('gone');
expect(refreshModels).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith('sidebar.scanFolderResult.missing', {}, 'warning');
});
it('reports a failed resolution without scanning', async () => {
const apiClient = createApiClient({
resolveFolder: vi.fn().mockRejectedValue(new Error('offline')),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras', '/mnt/usb/loras'] }),
});
const manager = createManager(apiClient);
const refreshModels = vi.fn();
manager.pageControls = { refreshModels };
await manager.scanFolder('pack');
// Multi-root + no resolver: guessing a root is exactly what the folder
// operations refuse to do, and a scan must not guess either.
expect(refreshModels).not.toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith('sidebar.scanFolderResult.missing', {}, 'warning');
});
it('does nothing when no page controls are registered', async () => {
const apiClient = createApiClient();
const manager = createManager(apiClient);
manager.pageControls = null;
manager.lastPageControls = null;
await manager.scanFolder('pack');
expect(showToast).not.toHaveBeenCalled();
});
});
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import path from 'path';
// Regression guard for the sidebar folder context-menu layout: the update check
// sits on top, the folder operations form a single group, and the destructive
// entry stays last behind its own divider. SidebarManager gates those groups
// per page and collapses the dividers when a group is hidden, so a reorder here
// also changes what the recipes page shows.
// Regression guard for the sidebar folder context-menu layout: the two refresh
// actions (scan this folder, check for updates) sit on top, the folder
// operations form a single group, and the destructive entry stays last behind
// its own divider. SidebarManager gates those groups per page and collapses the
// dividers when a group is hidden, so a reorder here also changes what the
// recipes page shows.
describe('Sidebar folder context menu layout', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const html = readFileSync(
@@ -24,8 +25,9 @@ describe('Sidebar folder context menu layout', () => {
return /data-action="([^"]+)"/.exec(rest)?.[1] || null;
});
it('keeps the update check first and the folder operations grouped', () => {
it('keeps the refresh actions first and the folder operations grouped', () => {
expect(sequence).toEqual([
'scan-folder',
'check-folder-updates',
'separator',
'create-subfolder',
+18
View File
@@ -352,6 +352,12 @@ describe('civitaiUtils', () => {
});
describe('extractCivitaiModelUrlParts', () => {
it.each(['civitai.com', 'civitai.red'])('rejects download and API URLs on %s', (host) => {
for (const path of ['/api/download/models/3390681', '/api/v1/models/3390681', '/models/3390681invalid']) {
expect(extractCivitaiModelUrlParts(`https://${host}${path}?modelVersionId=3390681`))
.toEqual({ modelId: null, modelVersionId: null });
}
});
it('extracts model and version ids from civitai.red model URLs', () => {
expect(
extractCivitaiModelUrlParts('https://civitai.red/models/65423/name?modelVersionId=98765')
@@ -373,9 +379,21 @@ describe('civitaiUtils', () => {
it('rejects image-like URLs from unsupported hosts', () => {
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
});
it.each(['civitai.com', 'civitai.red'])('rejects non-image pages on %s', (host) => {
for (const path of ['/api/v1/images/126920345', '/images/126920345invalid']) {
expect(extractCivitaiImageId(`https://${host}${path}`)).toBe(null);
}
});
});
describe('classifyModelRelinkUrl', () => {
it.each(['civitai.com', 'civitai.red', 'civarchive.com'])('rejects non-model pages on %s', (host) => {
for (const path of ['/api/download/models/3390681', '/api/v1/models/3390681', '/models/3390681invalid']) {
expect(classifyModelRelinkUrl(`https://${host}${path}`))
.toEqual({ source: null, modelId: null, modelVersionId: null });
}
});
it('classifies civitai.com model URLs', () => {
expect(
classifyModelRelinkUrl('https://civitai.com/models/649516/name?modelVersionId=726676')
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { UI_HELPERS_MODULE, I18N_MODULE } = vi.hoisted(() => ({
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
const { showToast } = await import(UI_HELPERS_MODULE);
describe('toast type styling', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('applies the requested type class', () => {
showToast('plain message', {}, 'success');
expect(document.querySelector('.toast').className).toBe('toast toast-success');
});
it('coerces an unknown type to info instead of rendering an unstyled toast', () => {
// Regression guard: a call site that passed its fallback sentence as the
// `type` produced `toast toast-drive-Z is not reachable ...`, which matched
// no rule at all (no icon, no accent border).
showToast('plain message', {}, 'drive-Z is not reachable right now');
expect(document.querySelector('.toast').className).toBe('toast toast-info');
});
it('defaults to info when no type is given', () => {
showToast('plain message');
expect(document.querySelector('.toast').className).toBe('toast toast-info');
});
});
+25 -7
View File
@@ -1,4 +1,4 @@
import types
import json
import pytest
@@ -130,12 +130,7 @@ def test_input_types_exposes_two_default_slots():
assert input_types["optional"]["lora_stack2"][0] == "LORA_STACK"
def test_input_types_recognizes_dynamic_slots_from_get_input_info(monkeypatch):
frames = [None, None, types.SimpleNamespace(function="get_input_info")]
monkeypatch.setattr(
"py.nodes.lora_stack_combiner.inspect.stack", lambda: frames
)
def test_input_types_resolves_dynamic_slots():
input_types = LoraStackCombinerLM.INPUT_TYPES()
optional = input_types["optional"]
@@ -145,6 +140,29 @@ def test_input_types_recognizes_dynamic_slots_from_get_input_info(monkeypatch):
assert optional["lora_stack25"][0] == "LORA_STACK"
def test_input_types_serializes_only_static_slots():
"""ComfyUI's /object_info route json.dumps INPUT_TYPES() directly."""
input_types = LoraStackCombinerLM.INPUT_TYPES()
payload = json.loads(json.dumps(input_types))
assert set(payload["optional"]) == {"lora_stack1", "lora_stack2"}
assert payload["optional"]["lora_stack1"][0] == "LORA_STACK"
def test_input_types_supports_get_input_info_lookup_order():
"""Mirror comfy_execution.graph.get_input_info()'s containment then lookup."""
valid_inputs = LoraStackCombinerLM.INPUT_TYPES()
input_name = "lora_stack7"
assert "optional" in valid_inputs
assert input_name in valid_inputs["optional"]
input_type, extra_info = valid_inputs["optional"][input_name]
assert input_type == "LORA_STACK"
assert "tooltip" in extra_info
def test_lora_stack_optional_inputs_proxy():
proxy = _LoraStackOptionalInputs({"lora_stack1": ("LORA_STACK", {})})
+24
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from typing import Any, cast
from py.nodes.prompt import PromptLM
@@ -63,6 +64,29 @@ def test_text_lm_input_types_expose_input_only_seed():
assert "wildcard generation" in cast(Any, seed_options)["tooltip"]
def test_prompt_lm_input_types_resolve_dynamic_trigger_words_slots():
optional = PromptLM.INPUT_TYPES()["optional"]
assert "trigger_words1" in optional
assert optional["trigger_words1"][0] == "STRING"
assert "trigger_words4" in optional
trigger_type, trigger_options = optional["trigger_words4"]
assert trigger_type == "STRING"
assert cast(Any, trigger_options)["forceInput"] is True
# Dynamic slots resolve on demand without becoming real entries.
assert "trigger_words4" not in dict(optional)
def test_prompt_lm_input_types_serialize_only_static_slots():
"""ComfyUI's /object_info route json.dumps INPUT_TYPES() directly."""
payload = json.loads(json.dumps(PromptLM.INPUT_TYPES()))
assert set(payload["optional"]) == {"seed", "trigger_words1"}
assert payload["optional"]["trigger_words1"][0] == "STRING"
def test_text_lm_is_changed_forces_rerun_without_seed_when_text_is_dynamic():
result = TextLM.IS_CHANGED("__flower__", seed=None)
@@ -901,3 +901,54 @@ def test_download_model_returns_skipped_success(mock_service, download_manager_s
await client.close()
asyncio.run(scenario())
def test_resolve_folder_answers_with_the_root_that_holds_the_directory(
mock_service, mock_scanner, tmp_path: Path
):
"""The route the sidebar needs to stop guessing a root for a tree node.
The unified folder tree merges every model root into one relative-path
namespace, so "recipes" below must resolve to the root that actually has it
even though another root is configured as the library default.
"""
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "recipes").mkdir(parents=True)
extra.mkdir()
mock_scanner.get_model_roots = lambda: [str(primary), str(extra)]
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get(
"/api/lm/test-models/resolve-folder", params={"folder": "recipes"}
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["folder"] == "recipes"
assert payload["candidates"] == [
{
"folder_path": str(primary / "recipes"),
"root": str(primary),
"is_symlink": False,
}
]
stale = await client.get(
"/api/lm/test-models/resolve-folder", params={"folder": "gone"}
)
assert (await stale.json())["candidates"] == []
refused = await client.get(
"/api/lm/test-models/resolve-folder",
params={"folder": str(primary / "recipes")},
)
assert refused.status == 400
finally:
await client.close()
asyncio.run(scenario())
+98 -1
View File
@@ -6,7 +6,7 @@ import subprocess
import zipfile
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch, MagicMock
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from aiohttp import web
@@ -16,6 +16,7 @@ from py.routes.handlers import misc_handlers
from py.routes.handlers.misc_handlers import (
BackupHandler,
DoctorHandler,
MiscHandlerSet,
FileSystemHandler,
HealthCheckHandler,
LoraCodeHandler,
@@ -36,6 +37,7 @@ from py.utils.session_logging import (
)
from py.routes.misc_route_registrar import MISC_ROUTE_DEFINITIONS, MiscRouteRegistrar
from py.routes.misc_routes import MiscRoutes
from py.services.errors import ResourceNotFoundError
def _json_payload(response) -> dict[str, Any]:
@@ -2747,6 +2749,38 @@ async def test_get_model_versions_status_supported_type_stays_interactive():
]
@pytest.mark.asyncio
async def test_get_model_versions_status_surfaces_not_found_details():
"""A missing CivitAI model keeps the provider's diagnostic message."""
class NotFoundProvider:
async def get_model_versions(self, _model_id):
raise ResourceNotFoundError("Civitai model 3390681 was not found")
async def metadata_factory():
return NotFoundProvider()
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=fake_scanner_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=fake_scanner_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=metadata_factory,
)
response = await handler.get_model_versions_status(
FakeRequest(query={"modelId": "3390681"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 404
assert payload["success"] is False
assert "3390681" in payload["error"]
class DummySidecarMigrationUseCase:
def __init__(self, result):
self.result = result
@@ -2865,3 +2899,66 @@ async def test_sidecar_migration_handler_relocate_root_requires_old_root():
assert response.status == 400
assert "old_root" in payload["error"]
assert use_case.calls == []
# --- Global price alerts panel endpoint -------------------------------------
class _AnyHandler:
def __getattr__(self, _name):
return lambda request: None
def _stub_misc_handler_set(**overrides) -> MiscHandlerSet:
names = (
"health",
"settings",
"usage_stats",
"lora_code",
"trained_words",
"model_examples",
"node_registry",
"model_library",
"metadata_archive",
"backup",
"filesystem",
"custom_words",
"wildcards",
"supporters",
"doctor",
"example_workflows",
"base_model",
"model_source_handler",
"agent_handler",
"download_routing",
"sidecar_migration",
)
handlers = {name: _AnyHandler() for name in names}
handlers.update(overrides)
return MiscHandlerSet(**handlers)
def test_every_misc_route_definition_resolves_to_a_handler():
"""A route added to the table without a mapping entry 500s only on a live
server, so assert the whole table resolves here."""
mapping = _stub_misc_handler_set().to_route_mapping()
assert [
definition.handler_name
for definition in MISC_ROUTE_DEFINITIONS
if definition.handler_name not in mapping
] == []
def test_price_alert_endpoints_are_gone():
"""The redesign removed the standalone alerts surface: obtainability is an
attribute of the update surfaces, so no route may serve an alert list."""
leftovers = [
definition
for definition in MISC_ROUTE_DEFINITIONS
if "price-alert" in definition.path or "price_alert" in definition.handler_name
]
assert leftovers == []
+63
View File
@@ -12,6 +12,7 @@ class FakeMoveService:
self.received_path = None
self.received_dry_run = None
self.received_new_name = None
self.received_folder = None
async def create_folder(self, folder_path):
self.received_path = folder_path
@@ -27,6 +28,10 @@ class FakeMoveService:
self.received_new_name = new_name
return self._result
def resolve_folder(self, folder):
self.received_folder = folder
return self._result
class FakeRequest:
def __init__(self, payload):
@@ -36,6 +41,13 @@ class FakeRequest:
return self._payload
class FakeQueryRequest:
"""Request stand-in exposing only the query string."""
def __init__(self, query):
self.query = query
def _make_handler(result):
service = FakeMoveService(result)
handler = ModelMoveHandler(
@@ -141,6 +153,43 @@ async def test_delete_folder_forwards_dry_run():
assert json.loads(response.text)["dry_run"] is True
@pytest.mark.asyncio
async def test_resolve_folder_returns_the_matching_roots():
handler, service = _make_handler(
{
"success": True,
"folder": "recipes",
"candidates": [
{
"folder_path": "/library/recipes",
"root": "/library",
"is_symlink": False,
}
],
}
)
response = await handler.resolve_folder(FakeQueryRequest({"folder": "recipes"}))
assert response.status == 200
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["candidates"][0]["folder_path"] == "/library/recipes"
assert service.received_folder == "recipes"
@pytest.mark.asyncio
async def test_resolve_folder_maps_a_refused_path_to_400():
handler, _service = _make_handler(
{"success": False, "error": "Folder path is required"}
)
response = await handler.resolve_folder(FakeQueryRequest({}))
assert response.status == 400
assert json.loads(response.text)["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_missing_path():
handler, service = _make_handler({"success": True})
@@ -206,6 +255,20 @@ async def test_delete_folder_containment_failure_maps_to_400():
assert "outside configured library" in payload["error"]
@pytest.mark.asyncio
async def test_delete_folder_reports_a_vanished_directory_as_missing():
handler, _service = _make_handler(
{"success": False, "code": "missing", "error": "Folder no longer exists"}
)
response = await handler.delete_folder(
FakeRequest({"folder_path": "/library/gone"})
)
assert response.status == 400
assert json.loads(response.text)["code"] == "missing"
@pytest.mark.asyncio
async def test_delete_folder_invalid_json_body():
class BadJsonRequest:
+228
View File
@@ -196,3 +196,231 @@ async def test_get_folder_tree_threads_include_empty():
assert payload["success"] is True
assert service.received_include_empty is True
class QueryParams(dict):
"""Minimal stand-in for aiohttp's MultiDict query (supports getall)."""
def getall(self, key, default=None):
value = self.get(key)
if value is None:
return list(default) if default else []
return value if isinstance(value, list) else [value]
class ScopedScanService:
"""Stub service recording the scan scope the handler resolves."""
model_type = "lora"
def __init__(self, roots=None, summary=None, cancelled=False, unavailable=None):
self._roots = list(roots or [])
self._unavailable = list(unavailable or [])
self._summary = summary
self.cancelled = cancelled
self.scanner = SimpleNamespace(is_cancelled=lambda: self.cancelled)
self.received_scope = "not-called"
self.received_rebuild = None
self.root_refreshes = 0
def get_model_roots(self):
return list(self._roots)
def refresh_model_roots(self):
"""Mimic the scanner admitting roots that became readable again."""
self.root_refreshes += 1
return []
def describe_model_roots(self):
return [
{
"path": root,
"label": root.rsplit("/", 1)[-1],
"reachable": True,
"available": True,
"models": 7,
}
for root in self._roots
] + [
{
"path": root,
"label": root.rsplit("/", 1)[-1],
"reachable": False,
"available": False,
"models": 3,
}
for root in self._unavailable
]
async def scan_models(self, force_refresh=False, rebuild_cache=False, scope=None):
self.received_scope = scope
self.received_rebuild = rebuild_cache
return self._summary
SUMMARY = {
"added": 3,
"removed": 0,
"repaired": 1,
"scanned_roots": ["a"],
"skipped_roots": [],
"unavailable_paths": [],
"unavailable_paths_total": 0,
"kept_unreachable": 0,
}
@pytest.mark.asyncio
async def test_scan_models_accepts_roots_param():
service = ScopedScanService(roots=["/mnt/a", "/mnt/b"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.scan_models(
SimpleNamespace(query=QueryParams({"roots": ["/mnt/a"]}))
)
payload = json.loads(response.text)
assert response.status == 200
assert payload["status"] == "success"
assert payload["added"] == 3
assert payload["scanned_roots"] == ["a"]
assert service.received_scope is not None
assert service.received_scope.roots == ("/mnt/a",)
assert service.received_rebuild is False
# A drive plugged in after startup is admitted before the roots are validated.
assert service.root_refreshes == 1
@pytest.mark.asyncio
async def test_scan_models_without_roots_scans_every_root():
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.scan_models(SimpleNamespace(query=QueryParams({})))
assert service.received_scope is None
@pytest.mark.asyncio
async def test_scan_models_rejects_unknown_root():
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.scan_models(
SimpleNamespace(query=QueryParams({"roots": ["/mnt/nope"]}))
)
payload = json.loads(response.text)
assert response.status == 400
assert payload["roots"] == ["/mnt/nope"]
assert service.received_scope == "not-called"
@pytest.mark.asyncio
async def test_scan_models_rejects_roots_with_full_rebuild():
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.scan_models(
SimpleNamespace(
query=QueryParams({"roots": ["/mnt/a"], "full_rebuild": "true"})
)
)
assert response.status == 400
assert service.received_scope == "not-called"
@pytest.mark.asyncio
async def test_get_model_roots_reports_configured_but_unavailable_roots():
"""Wave 6: a root whose directory was missing at startup is still reported."""
service = ScopedScanService(
roots=["/mnt/a"], unavailable=["/mnt/drive-Z/loras"]
)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.get_model_roots(SimpleNamespace(query=QueryParams({})))
payload = json.loads(response.text)
assert response.status == 200
assert payload["roots"] == ["/mnt/a"]
details = {entry["path"]: entry for entry in payload["root_details"]}
assert details["/mnt/a"]["available"] is True
assert details["/mnt/drive-Z/loras"]["available"] is False
assert details["/mnt/drive-Z/loras"]["reachable"] is False
assert details["/mnt/drive-Z/loras"]["models"] == 3
# Describing the roots admits anything that became readable again.
assert service.root_refreshes == 1
@pytest.mark.asyncio
async def test_scan_models_accepts_folder_param():
service = ScopedScanService(roots=["/mnt/a", "/mnt/b"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.scan_models(
SimpleNamespace(query=QueryParams({"folder": "pack\\sub"}))
)
assert response.status == 200
assert service.received_scope is not None
# Backslashes are normalized and no root is pinned: the backend walks this
# relative folder under every root that holds it.
assert service.received_scope.folder == "pack/sub"
assert service.received_scope.roots is None
@pytest.mark.asyncio
async def test_scan_models_rejects_absolute_folder():
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
for folder in ("/mnt/a/pack", "../pack", "C:/models/pack"):
response = await handler.scan_models(
SimpleNamespace(query=QueryParams({"folder": folder}))
)
assert response.status == 400, folder
assert "error" in json.loads(response.text)
assert service.received_scope == "not-called"
@pytest.mark.asyncio
async def test_scan_models_rejects_folder_with_full_rebuild():
service = ScopedScanService(roots=["/mnt/a"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.scan_models(
SimpleNamespace(query=QueryParams({"folder": "pack", "full_rebuild": "true"}))
)
assert response.status == 400
assert service.received_scope == "not-called"
@pytest.mark.asyncio
async def test_scan_models_combines_folder_and_roots():
service = ScopedScanService(roots=["/mnt/a", "/mnt/b"], summary=SUMMARY)
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.scan_models(
SimpleNamespace(query=QueryParams({"folder": "pack", "roots": ["/mnt/b"]}))
)
assert response.status == 200
assert service.received_scope.folder == "pack"
assert service.received_scope.roots == ("/mnt/b",)
@pytest.mark.asyncio
async def test_get_model_roots_reports_details():
service = ScopedScanService(roots=["/mnt/a", "/mnt/b"])
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.get_model_roots(SimpleNamespace(query=QueryParams({})))
payload = json.loads(response.text)
# `roots` stays a plain list of paths for the existing callers.
assert payload["roots"] == ["/mnt/a", "/mnt/b"]
assert [detail["label"] for detail in payload["root_details"]] == ["a", "b"]
assert all(detail["models"] == 7 for detail in payload["root_details"])
+153
View File
@@ -10,9 +10,12 @@ import pytest
from py.config import config
from py.routes.handlers.model_handlers import (
ModelCivitaiHandler,
ModelHandlerSet,
ModelManagementHandler,
ModelUpdateHandler,
)
from py.routes.model_route_registrar import COMMON_ROUTE_DEFINITIONS
from py.services.errors import ResourceNotFoundError
from py.services.service_registry import ServiceRegistry
from py.utils.metadata_manager import MetadataManager
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
@@ -229,6 +232,43 @@ async def test_get_civitai_versions_degrades_when_download_history_unavailable(m
assert payload[0]["hasBeenDownloaded"] is False
@pytest.mark.asyncio
async def test_get_civitai_versions_surfaces_not_found_details():
cache = SimpleNamespace(version_index={})
service = DummyService(cache)
class NotFoundProvider:
async def get_model_versions(self, model_id):
raise ResourceNotFoundError(f"Civitai model {model_id} was not found")
async def metadata_provider_factory():
return NotFoundProvider()
handler = ModelCivitaiHandler(
service=service,
settings_service=SimpleNamespace(get=lambda *_: False), # pyright: ignore[reportArgumentType]
ws_manager=SimpleNamespace(), # pyright: ignore[reportArgumentType]
logger=logging.getLogger(__name__),
metadata_provider_factory=metadata_provider_factory,
validate_model_type=lambda *_: True,
expected_model_types=lambda: "LoRA",
find_model_file=lambda *_: None,
metadata_sync=SimpleNamespace(), # pyright: ignore[reportArgumentType]
metadata_refresh_use_case=SimpleNamespace(), # pyright: ignore[reportArgumentType]
metadata_progress_callback=lambda *_args, **_kwargs: None, # pyright: ignore[reportArgumentType]
)
response = await handler.get_civitai_versions(
SimpleNamespace(match_info={"model_id": "42"}) # pyright: ignore[reportArgumentType]
)
text = response.text
assert text is not None
payload = json.loads(text)
assert response.status == 404
assert "42" in payload["error"]
@pytest.mark.asyncio
async def test_refresh_model_updates_filters_records_without_updates():
cache = SimpleNamespace(version_index={})
@@ -324,6 +364,72 @@ async def test_refresh_model_updates_filters_records_without_updates():
assert call["target_model_ids"] is None
@pytest.mark.asyncio
async def test_refresh_model_updates_reports_gate_events_for_all_records():
"""Gate transitions are reported even for records that do not qualify as
updates (a version already in the library that became free)."""
cache = SimpleNamespace(version_index={})
service = DummyService(cache)
record = ModelUpdateRecord(
model_type="lora",
model_id=1,
versions=[
ModelVersionRecord(
version_id=11,
name="v11",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=True,
should_ignore=False,
)
],
last_checked_at=None,
should_ignore_model=False,
events=[{"versionId": 11, "kind": "became_free", "versionName": "v11", "isInLibrary": True}],
)
update_service = DummyUpdateService({1: record})
metadata_selector = AsyncMock(return_value=SimpleNamespace())
handler = ModelUpdateHandler(
service=service,
update_service=update_service,
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
class DummyRequest:
can_read_body = True
query = {}
async def json(self):
return {}
response = await handler.refresh_model_updates(
DummyRequest() # pyright: ignore[reportArgumentType]
)
payload = json.loads(response.text)
# The record itself does not qualify as an update...
assert payload["records"] == []
# ...but its transition is still surfaced.
assert payload["events"] == [
{
"modelId": 1,
"modelType": "lora",
"versionId": 11,
"kind": "became_free",
"versionName": "v11",
"isInLibrary": True,
}
]
@pytest.mark.asyncio
async def test_refresh_model_updates_same_base_scope_excludes_cross_base_updates():
"""Issue #1083: with version_grouping=same_base (the default), a newer
@@ -1077,3 +1183,50 @@ async def test_relink_civitai_surfaces_provider_unavailable_without_500():
payload = json.loads(response.text)
assert payload["success"] is False
assert "CivitArchive" in payload["error"]
def test_every_common_route_definition_resolves_to_a_handler():
"""Guard the declarative route table against a missing mapping entry.
Adding a RouteDefinition without registering it in
``ModelHandlerSet.to_route_mapping`` fails at request time with a bare
KeyError from the handler lookup (and only on a live server), so assert the
whole table resolves here instead.
"""
class AnyHandler:
def __getattr__(self, _name):
return lambda request: None
handler_set = ModelHandlerSet(
page_view=AnyHandler(),
listing=AnyHandler(),
management=AnyHandler(),
query=AnyHandler(),
download=AnyHandler(),
civitai=AnyHandler(),
move=AnyHandler(),
auto_organize=AnyHandler(),
filename_template=AnyHandler(),
updates=AnyHandler(),
)
mapping = handler_set.to_route_mapping()
missing = [
definition.handler_name
for definition in COMMON_ROUTE_DEFINITIONS
if definition.handler_name not in mapping
]
assert missing == []
def test_price_alert_endpoints_are_not_registered():
"""The redesign dropped the standalone alerts surface: obtainability rides on
the update surfaces, so no per-type alert route may remain."""
assert [
definition.path_template
for definition in COMMON_ROUTE_DEFINITIONS
if "price-alert" in definition.path_template
] == []
+66 -1
View File
@@ -1,7 +1,8 @@
import asyncio
import os
import urllib.parse
from pathlib import Path
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from aiohttp import web
@@ -348,3 +349,67 @@ async def test_deep_symlink_discovered_on_first_access(tmp_path):
# Deep symlink should now be in mappings
assert normalized_external in config._path_mappings
async def test_preview_404_keeps_cache_when_parent_dir_missing(tmp_path):
"""A preview whose directory is unreachable (drive off) must not clear the
cached preview_url: nothing was deleted, the file just cannot be read."""
library_root = tmp_path / "library"
library_root.mkdir()
unreachable_parent = library_root / "gone"
preview_path = unreachable_parent / "model.webp"
config = Config()
config.apply_library_settings(
{
"folder_paths": {
"loras": [str(library_root)],
"checkpoints": [],
"unet": [],
"embeddings": [],
}
}
)
handler = PreviewHandler(config=config)
encoded_path = urllib.parse.quote(str(preview_path), safe="")
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
with patch.object(handler, "_cleanup_stale_preview_url", new=AsyncMock()) as cleanup:
with pytest.raises(web.HTTPNotFound):
await handler.serve_preview(request)
await asyncio.sleep(0)
cleanup.assert_not_called()
async def test_preview_404_clears_cache_when_file_really_deleted(tmp_path):
"""A preview deleted from a reachable directory is genuinely stale."""
library_root = tmp_path / "library"
library_root.mkdir()
preview_path = library_root / "model.webp"
preview_path.write_bytes(b"preview")
preview_path.unlink()
config = Config()
config.apply_library_settings(
{
"folder_paths": {
"loras": [str(library_root)],
"checkpoints": [],
"unet": [],
"embeddings": [],
}
}
)
handler = PreviewHandler(config=config)
encoded_path = urllib.parse.quote(str(preview_path), safe="")
request = make_mocked_request("GET", f"/api/lm/previews?path={encoded_path}")
with patch.object(handler, "_cleanup_stale_preview_url", new=AsyncMock()) as cleanup:
with pytest.raises(web.HTTPNotFound):
await handler.serve_preview(request)
await asyncio.sleep(0)
cleanup.assert_awaited_once()
+184
View File
@@ -1,4 +1,5 @@
import copy
import pathlib
from unittest.mock import AsyncMock
import pytest
@@ -198,6 +199,26 @@ async def test_get_model_versions_raises_on_not_found(monkeypatch, downloader):
await client.get_model_versions("missing")
@pytest.mark.parametrize("payload", ["Resource not found", {"error": "No model with id 3390681"}])
async def test_get_model_versions_not_found_explains_id_and_access(payload, downloader, caplog):
downloader.make_request = AsyncMock(return_value=(False, payload))
client = await CivitaiClient.get_instance()
with caplog.at_level("INFO"), pytest.raises(ResourceNotFoundError) as exc_info:
await client.get_model_versions("3390681")
message = str(exc_info.value)
assert "3390681" in message
assert "/models/<id>" in message
assert "modelVersionId" in message
assert "/api/download/models/<id>" in message
assert "API key" in message
assert "no longer available" not in caplog.text
downloader.make_request.assert_awaited_once_with(
"GET", f"{client.base_url}/models/3390681", use_auth=True
)
async def test_get_model_versions_raises_on_nested_not_found(monkeypatch, downloader):
async def fake_make_request(method, url, use_auth=True, **kwargs):
return False, {"error": {"message": "Resource not found"}}
@@ -859,3 +880,166 @@ async def test_get_version_file_mini_propagates_rate_limit(downloader):
with pytest.raises(RateLimitError):
await client.get_version_file_mini(1, 2)
async def test_get_model_prices_parses_public_page(downloader):
"""Prices come from the page payload, read anonymously (no API key)."""
client = await CivitaiClient.get_instance()
page_html = (
'<script id="__NEXT_DATA__" type="application/json">'
'{"props":{"pageProps":{"trpcState":{"json":{"queries":['
'{"queryKey":[["model","getById"],{"input":{"id":7}}],"state":{"data":'
'{"modelVersions":[{"id":42,"paidAccess":{"endsAt":null,'
'"timeframeDays":null,"terms":{"download":{"price":5000}},'
'"sale":null}}]}}}]}}}}}</script>'
)
async def fake_make_request(method, url, use_auth=True, **kwargs):
assert method == "GET"
assert "/models/7" in url
assert use_auth is False
assert kwargs.get("custom_headers", {}).get("Accept") == "text/html"
return True, page_html
downloader.make_request = fake_make_request
result = await client.get_model_prices(7)
assert result is not None
assert result[42]["price_buzz"] == 5000
async def test_get_model_prices_returns_none_on_unusable_page(downloader):
client = await CivitaiClient.get_instance()
async def fake_make_request(method, url, use_auth=True, **kwargs):
return True, "<html><body>challenge</body></html>"
downloader.make_request = fake_make_request
assert await client.get_model_prices(7) is None
async def test_get_model_prices_rejects_json_body(downloader):
"""A JSON response is not the page; it must not be parsed as one."""
client = await CivitaiClient.get_instance()
async def fake_make_request(method, url, use_auth=True, **kwargs):
return True, {"error": "nope"}
downloader.make_request = fake_make_request
assert await client.get_model_prices(7) is None
async def test_get_model_prices_propagates_rate_limit(downloader):
client = await CivitaiClient.get_instance()
async def fake_make_request(method, url, use_auth=True, **kwargs):
return False, RateLimitError("limited", retry_after=1.0)
downloader.make_request = fake_make_request
with pytest.raises(RateLimitError):
await client.get_model_prices(7)
# --- Model page host fallback -------------------------------------------------
_PAGE_FIXTURE = (
pathlib.Path(__file__).resolve().parents[1]
/ "utils"
/ "fixtures"
/ "civitai_model_page_paid.html"
)
def _page_html() -> str:
return _PAGE_FIXTURE.read_text(encoding="utf-8")
async def test_get_model_prices_falls_back_when_the_preferred_host_refuses(
downloader, monkeypatch
):
"""civitai.red refuses non-browser clients (Cloudflare); the price must still
be readable from a host that answers. This is the user-visible bug: with
civitai_host=civitai.red every fetch used to fail."""
client = await CivitaiClient.get_instance()
monkeypatch.setattr(client, "_page_host", lambda: "civitai.red")
seen = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
seen.append(url)
if "civitai.red" in url:
return False, "Access forbidden"
return True, _page_html()
downloader.make_request = fake_make_request
result = await client.get_model_prices(4242)
assert result is not None
assert result[1001]["price_buzz"] == 5000
assert seen[0].startswith("https://civitai.red/")
assert any("civitai.com" in url for url in seen)
# The working host is remembered and the refusing one is parked.
assert client._page_host_preference == "civitai.com"
assert "civitai.red" in client._page_host_blocked
async def test_get_model_prices_reuses_the_working_host(downloader, monkeypatch):
client = await CivitaiClient.get_instance()
monkeypatch.setattr(client, "_page_host", lambda: "civitai.red")
calls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
calls.append(url)
if "civitai.red" in url:
return False, "Access forbidden"
return True, _page_html()
downloader.make_request = fake_make_request
await client.get_model_prices(4242)
calls.clear()
await client.get_model_prices(4242)
# Second time around the parked host is not retried.
assert calls and all("civitai.red" not in url for url in calls)
async def test_get_model_prices_returns_none_when_no_host_can_serve(downloader, monkeypatch):
"""Mature models: 404 on civitai.com/green, challenge on civitai.red."""
client = await CivitaiClient.get_instance()
monkeypatch.setattr(client, "_page_host", lambda: "civitai.com")
async def fake_make_request(method, url, use_auth=True, **kwargs):
if "civitai.red" in url:
return False, "Access forbidden"
return False, "Resource not found"
downloader.make_request = fake_make_request
assert await client.get_model_prices(2981320) is None
async def test_get_model_prices_does_not_park_a_host_on_404(downloader, monkeypatch):
"""A 404 is model-specific (mature content hidden anonymously), not a reason
to stop using the host for other models."""
client = await CivitaiClient.get_instance()
monkeypatch.setattr(client, "_page_host", lambda: "civitai.com")
async def fake_make_request(method, url, use_auth=True, **kwargs):
if "civitai.com" in url:
return False, "Resource not found"
return False, "Access forbidden"
downloader.make_request = fake_make_request
await client.get_model_prices(1)
assert "civitai.com" not in client._page_host_blocked
assert "civitai.red" in client._page_host_blocked
+55 -2
View File
@@ -1969,7 +1969,7 @@ def _prepare_tracked_download(manager, download_id, status="downloading"):
manager._pause_events[download_id] = DownloadStreamControl()
async def _run_download(manager, download_id, tmp_path):
async def _run_download(manager, download_id, tmp_path, source=None):
return await manager._download_with_semaphore(
download_id,
1,
@@ -1978,7 +1978,7 @@ async def _run_download(manager, download_id, tmp_path):
"",
None,
False,
None,
source,
None,
False,
)
@@ -2063,6 +2063,59 @@ async def test_preflight_gate_blocks_when_host_in_cooldown(
manager._download_semaphore.release()
@pytest.mark.asyncio
async def test_preflight_gate_ignores_civarchive_cooldown_for_civitai_downloads(
monkeypatch, tmp_path, queue_service, reset_rate_limit_coordinator
):
"""A civarchive.com cooldown (armed e.g. by bulk metadata fetches) must
not block a plain CivitAI download that never touches that host."""
manager = DownloadManager()
monkeypatch.setattr(manager, "_cleanup_download_record", AsyncMock())
coordinator = await RateLimitCoordinator.get_instance()
coordinator.register_rate_limit("civarchive.com", 1800)
download_id = "dl-civarchive-cooldown"
await queue_service.add_to_queue(download_id=download_id, model_id=1)
_prepare_tracked_download(manager, download_id, status="waiting")
execute = AsyncMock(return_value={"success": True})
monkeypatch.setattr(manager, "_execute_original_download", execute)
result = await _run_download(manager, download_id, tmp_path)
assert execute.await_count == 1
assert result["success"] is True
@pytest.mark.asyncio
async def test_preflight_gate_blocks_civarchive_source_during_civarchive_cooldown(
monkeypatch, tmp_path, queue_service, reset_rate_limit_coordinator
):
"""Downloads explicitly sourced from CivArchive still gate on its cooldown."""
manager = DownloadManager()
monkeypatch.setattr(manager, "_cleanup_download_record", AsyncMock())
coordinator = await RateLimitCoordinator.get_instance()
coordinator.register_rate_limit("civarchive.com", 1800)
download_id = "dl-civarchive-source"
await queue_service.add_to_queue(download_id=download_id, model_id=1)
_prepare_tracked_download(manager, download_id, status="waiting")
execute = AsyncMock(
side_effect=AssertionError("download must not start during cooldown")
)
monkeypatch.setattr(manager, "_execute_original_download", execute)
result = await _run_download(manager, download_id, tmp_path, source="civarchive")
assert execute.await_count == 0
assert result["success"] is False
assert result["reason"] == "rate_limited"
assert "civarchive.com" in result["error"]
@pytest.mark.asyncio
async def test_rate_limit_retry_after_falls_back_to_coordinator_backoff(
monkeypatch, tmp_path, queue_service, reset_rate_limit_coordinator
+51 -1
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock
import pytest
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
from py.services.errors import RateLimitError
from py.services.errors import MetadataPersistError, RateLimitError
from py.services.metadata_sync_service import MetadataSyncService, _merge_ordered_unique
@@ -285,6 +285,56 @@ async def test_fetch_and_update_model_success_updates_cache(tmp_path):
update_cache.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_model_metadata_raises_when_sidecar_write_fails():
"""A failed sidecar write must surface instead of being swallowed."""
helpers = build_service()
helpers.metadata_manager.save_metadata.return_value = False
with pytest.raises(MetadataPersistError):
await helpers.service.update_model_metadata(
"path/to/model.metadata.json",
{"civitai": {}, "model_name": "Local"},
{"source": "api", "model": {"name": "Remote"}, "images": []},
helpers.default_provider,
)
@pytest.mark.asyncio
async def test_fetch_and_update_model_reports_sidecar_write_failure(tmp_path):
"""Fetch fails the item (and leaves the cache untouched) when the sidecar
cannot be written, so the model stays eligible for the next run."""
helpers = build_service()
helpers.metadata_manager.save_metadata.return_value = False
civitai_payload = {
"source": "api",
"model": {"name": "Remote", "description": "", "tags": ["tag"]},
"images": [],
"baseModel": "sdxl",
}
helpers.default_provider.get_model_by_hash.return_value = (civitai_payload, None)
model_path = tmp_path / "model.safetensors"
model_data: Dict[str, Any] = {
"model_name": "Local",
"folder": "root",
"file_path": str(model_path),
}
update_cache = AsyncMock(return_value=True)
ok, error = await helpers.service.fetch_and_update_model(
sha256="abc",
file_path=str(model_path),
model_data=model_data,
update_cache_func=update_cache,
)
assert ok is False
assert "sidecar" in (error or "")
update_cache.assert_not_awaited()
@pytest.mark.asyncio
async def test_fetch_and_update_model_keeps_deleted_flag_false_for_archive_source(tmp_path):
helpers = build_service()
+197 -1
View File
@@ -15,6 +15,7 @@ class FakeScanner:
self._roots = [str(root) for root in roots]
self.known_folders: List[str] = []
self.removed_folders: List[str] = []
self.removed_folder_paths: List[str | None] = []
self.renamed_folders: List[tuple] = []
self._excluded = list(excluded or [])
@@ -27,8 +28,9 @@ class FakeScanner:
async def add_known_folder(self, folder: str) -> None:
self.known_folders.append(folder)
async def remove_known_folder(self, folder: str) -> None:
async def remove_known_folder(self, folder: str, absolute_path: str | None = None) -> None:
self.removed_folders.append(folder)
self.removed_folder_paths.append(absolute_path)
async def rename_known_folder(self, previous: str, current: str, **kwargs) -> None:
self.renamed_folders.append((previous, current, kwargs))
@@ -107,6 +109,114 @@ async def test_create_folder_requires_path(tmp_path: Path):
assert result["success"] is False
def test_resolve_folder_finds_the_directory_under_the_root_that_holds_it(tmp_path: Path):
# The reported bug: the tree node lives under the primary root while the
# configured default root is the extra one. Resolution must answer with the
# directory that exists, not with a path fabricated from the default root.
primary = tmp_path / "primary"
extra = tmp_path / "extra"
primary.mkdir()
extra.mkdir()
(primary / "recipes").mkdir()
service = ModelMoveService(FakeScanner([primary, extra]), "lora")
result = service.resolve_folder("recipes")
assert result["success"] is True
assert result["folder"] == "recipes"
assert [Path(candidate["folder_path"]) for candidate in result["candidates"]] == [
primary / "recipes"
]
assert result["candidates"][0]["root"] == primary.as_posix()
assert result["candidates"][0]["is_symlink"] is False
def test_resolve_folder_returns_a_candidate_per_root_holding_the_folder(tmp_path: Path):
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "characters" / "anime").mkdir(parents=True)
(extra / "characters" / "anime").mkdir(parents=True)
service = ModelMoveService(FakeScanner([primary, extra]), "lora")
result = service.resolve_folder("characters/anime")
# Order follows the scanner's root order so the caller can prefer the
# default root without losing the rest.
assert [Path(candidate["folder_path"]) for candidate in result["candidates"]] == [
primary / "characters" / "anime",
extra / "characters" / "anime",
]
assert [candidate["root"] for candidate in result["candidates"]] == [
primary.as_posix(),
extra.as_posix(),
]
def test_resolve_folder_reports_no_candidate_for_a_stale_tree_node(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
service = ModelMoveService(FakeScanner([root]), "lora")
result = service.resolve_folder("gone")
assert result["success"] is True
assert result["candidates"] == []
def test_resolve_folder_flags_symlinked_candidates(tmp_path: Path):
root = tmp_path / "library"
root.mkdir()
real = tmp_path / "real"
real.mkdir()
try:
(root / "linked").symlink_to(real, target_is_directory=True)
except (OSError, NotImplementedError): # pragma: no cover - platform guard
pytest.skip("symlinks are not supported on this platform")
service = ModelMoveService(FakeScanner([root]), "lora")
result = service.resolve_folder("linked")
assert result["success"] is True
assert result["candidates"][0]["is_symlink"] is True
def test_resolve_folder_normalizes_the_relative_path(tmp_path: Path):
root = tmp_path / "library"
(root / "characters" / "anime").mkdir(parents=True)
service = ModelMoveService(FakeScanner([root]), "lora")
result = service.resolve_folder(" characters\\anime/ ")
assert result["success"] is True
assert result["folder"] == "characters/anime"
assert len(result["candidates"]) == 1
@pytest.mark.parametrize(
"folder",
["", " ", "/library/recipes", "C:/library/recipes", "..", "../outside", "a/../../b"],
)
def test_resolve_folder_rejects_paths_outside_the_relative_namespace(
tmp_path: Path, folder: str
):
root = tmp_path / "library"
root.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
service = ModelMoveService(FakeScanner([root]), "lora")
result = service.resolve_folder(folder)
assert result["success"] is False
assert "candidates" not in result
def _make_nested(root: Path) -> Path:
target = root / "characters" / "anime"
target.mkdir(parents=True)
@@ -127,6 +237,9 @@ async def test_delete_folder_removes_empty_directory_and_forgets_it(tmp_path: Pa
assert result["restorable"] is True
assert not target.exists()
assert scanner.removed_folders == ["characters/anime"]
# The scanner purges by the removed directory: the same relative folder may
# exist under another root, whose model cards must survive.
assert scanner.removed_folder_paths == [str(target)]
@pytest.mark.asyncio
@@ -515,3 +628,86 @@ async def test_rename_folder_requires_path(tmp_path: Path):
result = await service.rename_folder("", "renamed")
assert result["success"] is False
@pytest.mark.asyncio
async def test_delete_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch):
"""The default recipes dir lives in the first lora root and holds no model
weight files, so the model_count check alone would let it be wiped."""
from py.config import config
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
target = tmp_path / "recipes"
target.mkdir()
recipe_file = target / "abc123.recipe.json"
recipe_file.write_text("{}", encoding="utf-8")
result = await service.delete_folder(str(target))
assert result["success"] is False
assert result["code"] == "protected"
assert recipe_file.exists()
@pytest.mark.asyncio
async def test_delete_folder_refuses_an_ancestor_of_the_recipes_dir(
tmp_path: Path, monkeypatch
):
"""Deleting a parent of the recipes dir wipes the library just the same."""
from py.services.settings_manager import get_settings_manager
recipes_dir = tmp_path / "data" / "recipes"
recipes_dir.mkdir(parents=True)
manager = get_settings_manager()
monkeypatch.setitem(manager.settings, "recipes_path", str(recipes_dir))
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
result = await service.delete_folder(str(tmp_path / "data"))
assert result["success"] is False
assert result["code"] == "protected"
assert recipes_dir.is_dir()
@pytest.mark.asyncio
async def test_delete_folder_allows_normal_folders_next_to_the_recipes_dir(
tmp_path: Path, monkeypatch
):
from py.config import config
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
(tmp_path / "recipes").mkdir()
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
target = tmp_path / "recipes_backup"
target.mkdir()
result = await service.delete_folder(str(target))
assert result["success"] is True
assert not target.exists()
@pytest.mark.asyncio
async def test_rename_folder_refuses_the_recipes_dir(tmp_path: Path, monkeypatch):
from py.config import config
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
scanner = FakeScanner([tmp_path])
service = ModelMoveService(scanner, "lora")
target = tmp_path / "recipes"
target.mkdir()
result = await service.rename_folder(str(target), "recipes2")
assert result["success"] is False
assert result["code"] == "protected"
assert target.is_dir()
assert scanner.renamed_folders == []
+903 -3
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import json
import os
import shutil
import sqlite3
import threading
import time
@@ -16,7 +17,15 @@ import pytest
from py.services import model_scanner
from py.services.model_cache import ModelCache
from py.services.model_hash_index import ModelHashIndex
from py.services.model_scanner import CacheBuildResult, ModelScanner
from py.services.model_scanner import (
CacheBuildResult,
ModelScanner,
ReconcileScope,
_ReconcileWalkTracker,
_count_cached_entries_per_root,
_group_roots_by_device,
_root_display_labels,
)
from py.services.pending_delete_service import (
PENDING_DELETE_DIR_NAME,
PENDING_DELETE_TTL_SECONDS,
@@ -140,6 +149,17 @@ def _stub_service_registry_getters(monkeypatch) -> None:
monkeypatch.setattr(ServiceRegistry, "get_embedding_scanner", _none)
class TwoRootScanner(DummyScanner):
"""Scanner whose library spans two roots (primary + extra folder paths)."""
def __init__(self, primary: Path, extra: Path):
super().__init__(primary)
self._extra_root = str(extra)
def get_model_roots(self) -> List[str]:
return [self._root, self._extra_root]
def _create_files(root: Path) -> tuple[Path, Path, Path]:
first = root / "one.txt"
first.write_text("one", encoding="utf-8")
@@ -1562,12 +1582,16 @@ async def test_add_known_folder_ignores_empty_input(tmp_path: Path):
@pytest.mark.asyncio
async def test_remove_known_folder_drops_subtree_and_keeps_ancestors(tmp_path: Path):
_create_files(tmp_path)
(tmp_path / "nested" / "deep" / "leaf").mkdir(parents=True)
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
await scanner.add_known_folder("nested/deep/leaf")
assert "nested/deep" in cache.all_folders
await scanner.remove_known_folder("nested/deep")
# The delete the call reports has happened on disk.
shutil.rmtree(tmp_path / "nested" / "deep")
await scanner.remove_known_folder("nested/deep", str(tmp_path / "nested" / "deep"))
assert "nested/deep" not in cache.all_folders
assert "nested/deep/leaf" not in cache.all_folders
@@ -1583,7 +1607,8 @@ async def test_remove_known_folder_purges_stale_cache_entries(tmp_path: Path):
cache = await scanner.get_cached_data()
assert "nested" in cache.folders
await scanner.remove_known_folder("nested")
shutil.rmtree(tmp_path / "nested")
await scanner.remove_known_folder("nested", str(tmp_path / "nested"))
assert "nested" not in cache.all_folders
assert "nested" not in cache.folders
@@ -1592,6 +1617,102 @@ async def test_remove_known_folder_purges_stale_cache_entries(tmp_path: Path):
}
@pytest.mark.asyncio
async def test_remove_known_folder_legacy_call_prunes_the_entry_and_over_purges(
tmp_path: Path,
):
# Callers that only know the relative folder keep the old purge behaviour:
# it matches by folder name, so a surviving twin in another root loses its
# model cards until the next scan. That is exactly why the delete flow hands
# over the removed directory's absolute path.
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "shared").mkdir(parents=True)
(extra / "shared").mkdir(parents=True)
twin_model = extra / "shared" / "twin.txt"
twin_model.write_text("twin", encoding="utf-8")
scanner = TwoRootScanner(primary, extra)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
(primary / "shared").rmdir()
await scanner.remove_known_folder("shared")
# The entry itself is disk-verified even on this path: the twin keeps it.
assert "shared" in cache.all_folders
# ... while the purge, lacking a path, cannot tell the copies apart.
assert _normalize_path(twin_model) not in {
item["file_path"] for item in cache.raw_data
}
@pytest.mark.asyncio
async def test_remove_known_folder_keeps_a_twin_another_root_still_holds(tmp_path: Path):
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "test").mkdir(parents=True)
(extra / "test").mkdir(parents=True)
scanner = TwoRootScanner(primary, extra)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
assert "test" in cache.all_folders
# The user deleted the primary copy through the sidebar.
(primary / "test").rmdir()
await scanner.remove_known_folder("test", str(primary / "test"))
# The merged folder list is a union: the node stays while any root holds it,
# so it can no longer vanish and reappear on the next scan.
assert "test" in cache.all_folders
@pytest.mark.asyncio
async def test_remove_known_folder_drops_the_entry_once_no_root_holds_it(tmp_path: Path):
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "test").mkdir(parents=True)
(extra / "test").mkdir(parents=True)
scanner = TwoRootScanner(primary, extra)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
assert "test" in cache.all_folders
(primary / "test").rmdir()
(extra / "test").rmdir()
await scanner.remove_known_folder("test", str(primary / "test"))
assert "test" not in cache.all_folders
@pytest.mark.asyncio
async def test_remove_known_folder_purges_only_the_deleted_root_cards(tmp_path: Path):
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "shared").mkdir(parents=True)
(extra / "shared").mkdir(parents=True)
twin_model = extra / "shared" / "twin.txt"
twin_model.write_text("twin", encoding="utf-8")
scanner = TwoRootScanner(primary, extra)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
assert _normalize_path(twin_model) in {
item["file_path"] for item in cache.raw_data
}
# Empty copy in the primary root goes away.
(primary / "shared").rmdir()
await scanner.remove_known_folder("shared", str(primary / "shared"))
# Purging by the removed directory, not by its relative name: the twin's
# model card must survive in the other root.
assert _normalize_path(twin_model) in {
item["file_path"] for item in cache.raw_data
}
assert "shared" in cache.folders
@pytest.mark.asyncio
async def test_remove_known_folder_noop_without_recorded_folders(tmp_path: Path):
_create_files(tmp_path)
@@ -1621,6 +1742,41 @@ async def test_remove_known_folder_ignores_empty_input(tmp_path: Path):
assert cache.all_folders == before
@pytest.mark.asyncio
async def test_rename_known_folder_keeps_a_twin_in_another_root(tmp_path: Path):
primary = tmp_path / "primary"
extra = tmp_path / "extra"
(primary / "characters").mkdir(parents=True)
(extra / "characters").mkdir(parents=True)
twin_model = extra / "characters" / "twin.txt"
twin_model.write_text("twin", encoding="utf-8")
scanner = TwoRootScanner(primary, extra)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
twin_entry = next(
item for item in cache.raw_data
if item["file_path"] == _normalize_path(twin_model)
)
old_abs = _normalize_path(primary / "characters")
new_abs = _normalize_path(primary / "anime")
os.rename(primary / "characters", primary / "anime")
await scanner.rename_known_folder(
"characters", "anime", previous_path=old_abs, new_path=new_abs
)
# The renamed root contributes the new name; the other root still owns the
# old one, so both stay in the merged list.
assert "anime" in cache.all_folders
assert "characters" in cache.all_folders
# And the twin's records are untouched: its files never moved.
assert twin_entry["folder"] == "characters"
assert twin_entry["file_path"] == _normalize_path(twin_model)
assert "characters" in cache.folders
@pytest.mark.asyncio
async def test_rename_known_folder_rekeys_folders_cache_and_sidecar(tmp_path: Path):
_, second, _ = _create_files(tmp_path)
@@ -2049,3 +2205,747 @@ async def test_reconcile_cache_broadcasts_error(tmp_path: Path, monkeypatch):
assert messages[0]["status"] == "started"
assert messages[-1]["status"] == "error"
assert messages[-1]["error"] == "walk failed"
# --- reconcile walk: device grouping, parallel workers, walk progress -------
def test_reconcile_walk_tracker_reports_weighted_progress():
tracker = _ReconcileWalkTracker(["/mnt/g", "/mnt/y"], {"/mnt/g": 100, "/mnt/y": 0})
snapshot = tracker.snapshot()
assert snapshot is not None
assert snapshot["progress"] == 0
assert snapshot["files_seen"] == 0
assert snapshot["roots_total"] == 2
assert snapshot["roots_done"] == 0
assert snapshot["active_roots"] == []
# No files seen yet: the total never drops below what was walked.
assert snapshot["total"] == 100
tracker.start_root("/mnt/g")
tracker.report("/mnt/g", 50)
snapshot = tracker.snapshot()
# Half of the 100-weight root, out of 101 total weight -> 24.75% of 50.
assert snapshot["progress"] == 24
assert snapshot["files_seen"] == 50
assert snapshot["active_roots"] == ["g"]
assert snapshot["current_name"] == "g"
# A root with no cached entries contributes one weight unit and only counts
# once it is finished.
tracker.finish_root("/mnt/g")
tracker.start_root("/mnt/y")
tracker.report("/mnt/y", 10)
snapshot = tracker.snapshot()
assert snapshot["roots_done"] == 1
assert snapshot["progress"] == int((100 / 101) * 50)
assert snapshot["active_roots"] == ["y"]
tracker.finish_root("/mnt/y")
snapshot = tracker.snapshot()
assert snapshot["progress"] == 50
assert snapshot["roots_done"] == 2
assert snapshot["files_seen"] == 60
# The estimate is the cached entry count, so it also covers new files.
assert snapshot["total"] == 100
# A library that grew past the estimate raises the reported total instead of
# overshooting it (the client ETA divides by it).
tracker.start_root("/mnt/g")
tracker.report("/mnt/g", 150)
assert tracker.snapshot()["total"] == 160
assert _ReconcileWalkTracker([], {}).snapshot() is None
def test_reconcile_walk_tracker_snapshot_is_json_serializable():
"""The snapshot goes straight into a WebSocket payload."""
tracker = _ReconcileWalkTracker(["/mnt/g"], {"/mnt/g": 2})
tracker.start_root("/mnt/g")
tracker.report("/mnt/g", 1)
snapshot = tracker.snapshot()
assert snapshot is not None
assert json.loads(json.dumps(snapshot)) == snapshot
def test_group_roots_by_device_keeps_configured_order(tmp_path: Path, monkeypatch):
device_by_root = {"a": "dev1", "b": "dev1", "c": "dev2"}
monkeypatch.setattr(
model_scanner, "_root_device_key", lambda root: device_by_root[root]
)
assert _group_roots_by_device(["a", "b", "c"]) == [["a", "b"], ["c"]]
monkeypatch.undo()
# Real layout: roots under one temp dir share a device and stay sequential.
first = tmp_path / "one"
second = tmp_path / "two"
third = tmp_path / "three"
for path in (first, second, third):
path.mkdir()
assert _group_roots_by_device([str(first), str(second), str(third)]) == [
[str(first), str(second), str(third)]
]
def test_count_cached_entries_per_root_uses_longest_prefix():
counts = _count_cached_entries_per_root(
{
"/m/loras/a.txt",
"/m/loras/sub/b.txt",
"/m/loras2/c.txt",
"/somewhere/else/d.txt",
},
["/m/loras", "/m/loras2"],
)
assert counts == {"/m/loras": 2, "/m/loras2": 1}
@pytest.mark.asyncio
async def test_reconcile_case_fold_fallback_is_indexed_not_linear(
tmp_path: Path, monkeypatch
):
"""The Windows case fallback must resolve through a lower-cased index built
once, not by scanning every cached path per miss (was O(files x cached))."""
root = tmp_path / "loras"
root.mkdir()
for name in ("one", "two", "three"):
(root / f"{name}.txt").write_text(name, encoding="utf-8")
scanner = DummyScanner(root)
await scanner._initialize_cache()
# Rewrite the cached paths to a case variant that does not exist on disk:
# the realpath alias index cannot match it, so only the case-fold fallback
# can keep these entries.
for item in scanner._cache.raw_data:
stem = os.path.splitext(os.path.basename(item["file_path"]))[0]
item["file_name"] = stem.upper()
item["file_path"] = _normalize_path(root / f"{stem.upper()}.TXT")
monkeypatch.setattr(model_scanner, "_CASE_INSENSITIVE_PATHS", True)
builds = 0
real_build = model_scanner._build_casefold_index
def _counting_build(cached_paths):
nonlocal builds
builds += 1
return real_build(cached_paths)
monkeypatch.setattr(model_scanner, "_build_casefold_index", _counting_build)
processed: List[str] = []
async def _record_process(file_path, root_path, *args, **kwargs):
processed.append(file_path)
return await DummyScanner._process_model_file(
scanner, file_path, root_path, *args, **kwargs
)
scanner._process_model_file = _record_process # type: ignore[method-assign]
await scanner._reconcile_cache()
cache = await scanner.get_cached_data()
assert {item["file_path"] for item in cache.raw_data} == {
_normalize_path(root / "ONE.TXT"),
_normalize_path(root / "TWO.TXT"),
_normalize_path(root / "THREE.TXT"),
}
assert processed == []
assert builds == 1
@pytest.mark.asyncio
async def test_reconcile_walks_different_devices_in_parallel(tmp_path: Path, monkeypatch):
"""Roots on different devices are walked concurrently, and their results are
still merged in configured root order (not completion order)."""
root_a = tmp_path / "a"
root_b = tmp_path / "b"
root_a.mkdir()
root_b.mkdir()
scanner = MultiRootDummyScanner([root_a, root_b])
await scanner._initialize_cache()
monkeypatch.setattr(
model_scanner,
"_root_device_key",
lambda root: "dev-a" if str(root) == str(root_a) else "dev-b",
)
barrier = threading.Barrier(2, timeout=5)
overlap_violations: List[str] = []
real_walk = os.walk
def _walk_with_barrier(path, *args, **kwargs):
def _generate():
first = True
for entry in real_walk(path, *args, **kwargs):
if first:
first = False
try:
barrier.wait()
except threading.BrokenBarrierError:
overlap_violations.append(str(path))
if str(path) == str(root_a):
# Root A is first in configured order but finishes last.
time.sleep(0.05)
yield entry
return _generate()
monkeypatch.setattr(model_scanner.os, "walk", _walk_with_barrier)
new_a = root_a / "a_new.txt"
new_a.write_text("a", encoding="utf-8")
new_b = root_b / "b_new.txt"
new_b.write_text("b", encoding="utf-8")
processed: List[str] = []
async def _record_process(file_path, root_path, *args, **kwargs):
processed.append(file_path)
return await DummyScanner._process_model_file(
scanner, file_path, root_path, *args, **kwargs
)
scanner._process_model_file = _record_process # type: ignore[method-assign]
await scanner._reconcile_cache()
assert overlap_violations == [], "roots on different devices were not walked concurrently"
assert processed == [
_normalize_path(new_a),
_normalize_path(new_b),
]
@pytest.mark.asyncio
async def test_reconcile_walk_broadcasts_per_root_progress(tmp_path: Path, monkeypatch):
root = tmp_path / "loras"
root.mkdir()
for i in range(4):
sub = root / f"dir{i}"
sub.mkdir()
(sub / "one.txt").write_text("x", encoding="utf-8")
scanner = DummyScanner(root)
await scanner._initialize_cache()
ws_stub = RecordingWebSocketManager()
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
monkeypatch.setattr(model_scanner, "_WALK_PROGRESS_INTERVAL_SECONDS", 0.01)
real_walk = os.walk
def _slow_walk(path, *args, **kwargs):
def _generate():
for entry in real_walk(path, *args, **kwargs):
time.sleep(0.05)
yield entry
return _generate()
monkeypatch.setattr(model_scanner.os, "walk", _slow_walk)
await scanner._reconcile_cache()
walk_messages = [
message
for message in ws_stub.broadcasts
if message["stage"] == "reconcile_scan" and message["status"] == "processing"
]
# More than the final snapshot => the monitor ticked during the walk.
assert len(walk_messages) >= 2
# Intermediate ticks name the root being walked.
assert any(message["active_roots"] == ["loras"] for message in walk_messages)
final = walk_messages[-1]
assert final["files_seen"] == 4
assert final["processed"] == final["files_seen"]
assert final["total"] >= final["files_seen"]
assert final["roots_total"] == 1
assert final["roots_done"] == 1
assert final["active_roots"] == []
assert final["current_name"] == "loras"
assert 0 < final["progress"] <= 50
assert final["full_rebuild"] is False
assert walk_messages[0]["progress"] <= final["progress"]
@pytest.mark.asyncio
async def test_reconcile_keeps_entries_of_an_offline_root(tmp_path: Path):
"""A configured root that is gone (drive switched off) must not be treated
as deleted: its entries are kept and reported as unreachable instead."""
root_a = tmp_path / "a"
root_b = tmp_path / "b"
root_a.mkdir()
root_b.mkdir()
file_a = root_a / "a0.txt"
file_a.write_text("a", encoding="utf-8")
file_b = root_b / "b0.txt"
file_b.write_text("b", encoding="utf-8")
scanner = MultiRootDummyScanner([root_a, root_b])
await scanner._initialize_cache()
assert len(scanner._cache.raw_data) == 2
root_b.rename(tmp_path / "b_offline")
summary = await scanner._reconcile_cache()
remaining = {item["file_path"] for item in scanner._cache.raw_data}
assert remaining == {_normalize_path(file_a), _normalize_path(file_b)}
assert scanner._hash_index.get_path("hash-b0") == _normalize_path(file_b)
assert scanner._hash_index.get_path("hash-a0") == _normalize_path(file_a)
assert summary is not None
assert summary["removed"] == 0
assert summary["kept_unreachable"] == 1
assert summary["unavailable_paths_total"] == 1
assert [entry["path"] for entry in summary["unavailable_paths"]] == [
_normalize_path(root_b)
]
assert summary["unavailable_paths"][0]["reason"] == "root_unreachable"
assert [entry["path"] for entry in summary["skipped_roots"]] == [
_normalize_path(root_b)
]
# --- scoped scans (issue #1108) --------------------------------------------
@pytest.mark.asyncio
async def test_reconcile_scoped_scan_leaves_other_roots_untouched(tmp_path: Path):
"""Scanning one root must not add, remove or re-read anything in another."""
root_a = tmp_path / "a"
root_b = tmp_path / "b"
root_a.mkdir()
root_b.mkdir()
(root_a / "a0.txt").write_text("a", encoding="utf-8")
(root_b / "b0.txt").write_text("b", encoding="utf-8")
scanner = MultiRootDummyScanner([root_a, root_b])
await scanner._initialize_cache()
# Changes in B stay invisible while only A is scanned.
(root_b / "b0.txt").unlink()
(root_b / "b_new.txt").write_text("new", encoding="utf-8")
new_a = root_a / "a_new.txt"
new_a.write_text("new", encoding="utf-8")
summary = await scanner._reconcile_cache(scope=ReconcileScope(roots=(str(root_a),)))
assert summary is not None
assert summary["added"] == 1
assert summary["removed"] == 0
assert summary["scanned_roots"] == ["a"]
cached_paths = {item["file_path"] for item in scanner._cache.raw_data}
assert cached_paths == {
_normalize_path(root_a / "a0.txt"),
_normalize_path(new_a),
_normalize_path(root_b / "b0.txt"),
}
@pytest.mark.asyncio
async def test_reconcile_scoped_scan_removes_deleted_files_in_scope(tmp_path: Path):
"""Deletions inside the scanned root still reconcile normally."""
root_a = tmp_path / "a"
root_b = tmp_path / "b"
root_a.mkdir()
root_b.mkdir()
deleted_a = root_a / "a0.txt"
deleted_a.write_text("a", encoding="utf-8")
(root_b / "b0.txt").write_text("b", encoding="utf-8")
scanner = MultiRootDummyScanner([root_a, root_b])
await scanner._initialize_cache()
deleted_a.unlink()
summary = await scanner._reconcile_cache(scope=ReconcileScope(roots=(str(root_a),)))
assert summary is not None
assert summary["removed"] == 1
assert scanner._hash_index.get_path("hash-a0") is None
assert {item["file_path"] for item in scanner._cache.raw_data} == {
_normalize_path(root_b / "b0.txt")
}
@pytest.mark.asyncio
async def test_reconcile_folder_scope_keeps_folder_tree_outside_scope(tmp_path: Path):
"""A folder-scoped scan must not collapse the recorded folder list to the
scanned subtree (the sidebar would lose every other folder)."""
root = tmp_path / "loras"
(root / "alpha").mkdir(parents=True)
(root / "beta").mkdir()
(root / "alpha" / "m.txt").write_text("m", encoding="utf-8")
scanner = DummyScanner(root)
await scanner._initialize_cache()
folders_before = set(scanner._cache.all_folders or [])
assert "beta" in folders_before
(root / "alpha" / "nested").mkdir()
(root / "alpha" / "m.txt").unlink()
summary = await scanner._reconcile_cache(scope=ReconcileScope(folder="alpha"))
assert summary is not None
assert summary["removed"] == 1
folders_after = set(scanner._cache.all_folders or [])
assert "beta" in folders_after
assert "alpha/nested" in folders_after
assert {item["file_path"] for item in scanner._cache.raw_data} == set()
@pytest.mark.asyncio
async def test_reconcile_folder_scope_covers_every_root_holding_it(tmp_path: Path):
"""The sidebar's unified tree has no root identity: a folder scope means
"this relative folder under every root that holds it"."""
primary = tmp_path / "primary"
usb = tmp_path / "usb"
for root in (primary, usb):
(root / "pack").mkdir(parents=True)
(root / "other").mkdir()
(root / "other" / "keep.txt").write_text("k", encoding="utf-8")
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
(usb / "pack" / "b.txt").write_text("b", encoding="utf-8")
# Only the primary root holds this folder; the usb copy is gone.
(primary / "only-primary").mkdir()
(primary / "only-primary" / "c.txt").write_text("c", encoding="utf-8")
(usb / "only-primary").mkdir()
scanner = MultiRootDummyScanner([primary, usb])
await scanner._initialize_cache()
(usb / "pack" / "b.txt").unlink()
(primary / "only-primary" / "c.txt").unlink()
(primary / "pack" / "new.txt").write_text("n", encoding="utf-8")
summary = await scanner._reconcile_cache(scope=ReconcileScope(folder="pack"))
assert summary is not None
assert summary["scope_label"] == "pack"
assert summary["added"] == 1
assert summary["removed"] == 1
paths = {item["file_path"] for item in scanner._cache.raw_data}
# Both copies of the scoped folder were walked...
assert _normalize_path(primary / "pack" / "new.txt") in paths
assert _normalize_path(usb / "pack" / "b.txt") not in paths
# ...and nothing outside it was touched, including the folder that only one
# root holds.
assert _normalize_path(usb / "other" / "keep.txt") in paths
assert _normalize_path(primary / "only-primary" / "c.txt") in paths
async def _startup_filtered_scanner(tmp_path: Path, monkeypatch, configured_missing: Path):
"""Scanner whose live root list lacks a root that the config still holds.
Models the startup case: the directory did not exist when the live list was
built (drive switched off), so `Config` filtered it out while its cached
entries are still there.
"""
primary = tmp_path / "primary"
(primary / "pack").mkdir(parents=True)
(primary / "pack" / "a.txt").write_text("a", encoding="utf-8")
configured_missing.mkdir(parents=True)
(configured_missing / "pack").mkdir()
(configured_missing / "pack" / "b.txt").write_text("b", encoding="utf-8")
scanner = MultiRootDummyScanner([primary, configured_missing])
await scanner._initialize_cache()
# The drive goes away before the process starts, as far as the live list is
# concerned: keep the roots the filter would have kept.
shutil.rmtree(configured_missing)
scanner._roots = [str(primary)]
monkeypatch.setattr(
scanner,
"get_configured_model_roots",
lambda: [str(primary), str(configured_missing)],
)
return scanner, primary, configured_missing
@pytest.mark.asyncio
async def test_describe_model_roots_reports_unavailable_configured_root(
tmp_path: Path, monkeypatch
):
configured_missing = tmp_path / "drive-Z" / "loras"
scanner, primary, missing = await _startup_filtered_scanner(
tmp_path, monkeypatch, configured_missing
)
described = scanner.describe_model_roots()
assert [entry["path"] for entry in described] == [
_normalize_path(primary),
_normalize_path(missing),
]
available, unavailable = described
assert available["reachable"] is True
assert available["available"] is True
# The missing root is reported with its cached count so the refresh menu can
# offer it as an offline row instead of hiding it until a restart.
assert unavailable["reachable"] is False
assert unavailable["available"] is False
assert unavailable["models"] == 1
@pytest.mark.asyncio
async def test_reconcile_reports_root_filtered_out_at_startup(tmp_path: Path, monkeypatch):
configured_missing = tmp_path / "drive-Z" / "loras"
scanner, _primary, missing = await _startup_filtered_scanner(
tmp_path, monkeypatch, configured_missing
)
kept_path = _normalize_path(missing / "pack" / "b.txt")
summary = await scanner._reconcile_cache()
assert summary is not None
# Nothing is added or removed...
assert summary["added"] == 0
assert summary["removed"] == 0
# ...but the switched-off root is no longer silent: it is reported with the
# entries that were kept for it.
assert summary["skipped_roots"] == [
{
"path": _normalize_path(missing),
"label": missing.name,
"reason": "root_unavailable",
}
]
assert summary["kept_unreachable"] == 1
assert summary["unavailable_paths"] == [
{"path": _normalize_path(missing), "reason": "root_unavailable", "kept": 1}
]
assert kept_path in {item["file_path"] for item in scanner._cache.raw_data}
@pytest.mark.asyncio
async def test_scoped_scan_stays_quiet_about_filtered_out_roots(tmp_path: Path, monkeypatch):
configured_missing = tmp_path / "drive-Z" / "loras"
scanner, primary, missing = await _startup_filtered_scanner(
tmp_path, monkeypatch, configured_missing
)
summary = await scanner._reconcile_cache(
scope=ReconcileScope(roots=(str(primary),))
)
assert summary is not None
# The caller asked for one root: a root it did not ask about is not its
# business, but its entries are still kept.
assert summary["scanned_roots"] == [primary.name]
assert "root_unavailable" not in {
entry["reason"] for entry in summary["skipped_roots"]
}
assert _normalize_path(missing / "pack" / "b.txt") in {
item["file_path"] for item in scanner._cache.raw_data
}
@pytest.mark.asyncio
async def test_reconcile_keeps_entries_under_unreadable_dir(tmp_path: Path, monkeypatch):
"""A directory os.walk cannot enter (permissions, I/O error, offline
junction) keeps its cached entries instead of losing them."""
root = tmp_path / "loras"
sub = root / "sub"
sub.mkdir(parents=True)
(sub / "m.txt").write_text("m", encoding="utf-8")
(root / "top.txt").write_text("t", encoding="utf-8")
scanner = DummyScanner(root)
await scanner._initialize_cache()
real_scandir = os.scandir
def _failing_scandir(path=".", *args, **kwargs):
if os.path.normpath(str(path)) == os.path.normpath(str(sub)):
raise PermissionError(13, "Permission denied", str(sub))
return real_scandir(path, *args, **kwargs)
monkeypatch.setattr(model_scanner.os, "scandir", _failing_scandir)
summary = await scanner._reconcile_cache()
assert summary is not None
assert summary["removed"] == 0
assert summary["kept_unreachable"] == 1
assert summary["unavailable_paths"][0]["reason"] == "unreadable_dir"
assert _normalize_path(sub / "m.txt") in {
item["file_path"] for item in scanner._cache.raw_data
}
@pytest.mark.asyncio
async def test_reconcile_keeps_entries_under_offline_first_level_symlink(
tmp_path: Path, monkeypatch
):
"""A known first-level symlink whose target is gone protects the entries
that live under the link path (the linked drive is switched off)."""
root = tmp_path / "loras"
linked = root / "linked"
linked.mkdir(parents=True)
(linked / "m.txt").write_text("m", encoding="utf-8")
scanner = DummyScanner(root)
await scanner._initialize_cache()
# The linked folder goes away with its drive; config still knows the link.
linked.rename(tmp_path / "linked_offline")
monkeypatch.setattr(
model_scanner.config,
"iter_path_mappings",
lambda: [(str(tmp_path / "gone_target"), str(linked))],
)
summary = await scanner._reconcile_cache()
assert summary is not None
assert summary["removed"] == 0
assert summary["kept_unreachable"] == 1
assert summary["unavailable_paths"][0]["reason"] == "symlink_target_offline"
assert _normalize_path(linked / "m.txt") in {
item["file_path"] for item in scanner._cache.raw_data
}
def test_root_display_labels_dedupe_by_parent_segments(monkeypatch):
labels = _root_display_labels(["/mnt/usb/loras", "/mnt/ssd/loras"])
assert labels == {"/mnt/usb/loras": "usb/loras", "/mnt/ssd/loras": "ssd/loras"}
# Still colliding after one parent segment: keep growing leftwards.
labels = _root_display_labels(["/mnt/a/models/loras", "/mnt/b/models/loras"])
assert labels == {
"/mnt/a/models/loras": "a/models/loras",
"/mnt/b/models/loras": "b/models/loras",
}
# A single root keeps the short form.
assert _root_display_labels(["/mnt/usb/loras"]) == {"/mnt/usb/loras": "loras"}
# Windows drive prefixes disambiguate on their own.
def _fake_splitdrive(path):
for drive in ("G:", "H:"):
if path.startswith(drive):
return drive, path[len(drive):]
return "", path
monkeypatch.setattr(model_scanner.os.path, "splitdrive", _fake_splitdrive)
labels = _root_display_labels(["G:\\x\\loras", "H:\\y\\loras"])
assert labels == {"G:\\x\\loras": "G: loras", "H:\\y\\loras": "H: loras"}
# Identical renderings (trailing separator / duplicated config entry) grow to
# the full path and then fall back to a deterministic suffix instead of
# silently sharing a label.
labels = _root_display_labels(["/mnt/x/loras", "/mnt/x/loras/"])
assert labels == {
"/mnt/x/loras": "mnt/x/loras (1)",
"/mnt/x/loras/": "mnt/x/loras (2)",
}
long_root = "/mnt/" + "d" * 60 + "/loras"
label = _root_display_labels([long_root])[long_root]
assert len(label) <= 40
assert label.endswith("loras")
@pytest.mark.asyncio
async def test_scan_excludes_recipes_dir_from_all_folders(tmp_path: Path, monkeypatch):
"""The default recipes dir lives under the first lora root; it is a
reserved directory and must never appear in the folder tree."""
_create_files(tmp_path)
recipes = tmp_path / "recipes"
(recipes / "sub").mkdir(parents=True)
# A stray weight file inside the recipes dir is not indexed either.
(recipes / "stray.txt").write_text("stray", encoding="utf-8")
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
assert not any("/recipes/" in item["file_path"] for item in cache.raw_data)
all_folders = await scanner.get_all_folders()
assert "recipes" not in all_folders
assert "recipes/sub" not in all_folders
@pytest.mark.asyncio
async def test_all_folders_backfill_excludes_recipes_dir(tmp_path: Path, monkeypatch):
_create_files(tmp_path)
(tmp_path / "recipes").mkdir()
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
cache.all_folders = None
await scanner.get_all_folders()
for _ in range(200):
if not scanner._all_folders_backfill_running:
break
await asyncio.sleep(0.01)
assert scanner._all_folders_backfill_running is False
assert cache.all_folders is not None
assert "recipes" not in cache.all_folders
assert "nested" in cache.all_folders
@pytest.mark.asyncio
async def test_get_all_folders_filters_recipes_dir_from_legacy_snapshots(
tmp_path: Path, monkeypatch
):
"""Snapshots persisted before the walk exclusion still carry the recipes
dir; readers filter it out until the next scan rewrites the list."""
_create_files(tmp_path)
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
scanner = DummyScanner(tmp_path)
await scanner._initialize_cache()
cache = await scanner.get_cached_data()
cache.all_folders = sorted(
set(cache.all_folders or []) | {"recipes", "recipes/sub"},
key=lambda value: value.lower(),
)
all_folders = await scanner.get_all_folders()
assert "recipes" not in all_folders
assert "recipes/sub" not in all_folders
assert "nested" in all_folders
def test_reconcile_walk_skips_the_recipes_dir(tmp_path: Path, monkeypatch):
(tmp_path / "recipes" / "sub").mkdir(parents=True)
(tmp_path / "keep").mkdir()
monkeypatch.setattr(model_scanner.config, "loras_roots", [str(tmp_path)])
result = model_scanner._walk_root_for_reconcile(
root_path=str(tmp_path),
file_extensions={".txt"},
cached_paths=set(),
path_to_item={},
lookups=model_scanner._CachedPathLookups(set()),
dir_claims=model_scanner._RealDirClaims(),
excluded_models=set(),
is_cancelled=lambda: False,
report_progress=lambda _files_seen: None,
)
assert "keep" in result.discovered_folders
assert "recipes" not in result.discovered_folders
assert "recipes/sub" not in result.discovered_folders
+844 -2
View File
@@ -1,5 +1,6 @@
import logging
import sqlite3
from dataclasses import replace
from types import SimpleNamespace
import pytest
@@ -826,6 +827,32 @@ def test_extract_single_version_paid_access_permanent(tmp_path):
assert version.paid_access is not None
def test_extract_single_version_paid_access_pending_end(tmp_path):
"""A timed gate whose window end is not recorded yet
({"permanent": false, "endsAt": null}) is still an active gate, so it is
early access with no known end date rather than a free version."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
entry = {
"id": 42,
"name": "v1 paid",
"availability": "Public",
"paidAccess": {"permanent": False, "endsAt": None},
"files": [],
"images": [],
}
version = service._extract_single_version(entry, index=0)
assert version is not None
assert version.is_early_access is True
assert version.is_paid is False
assert version.early_access_ends_at is None
assert version.paid_access == '{"permanent": false, "endsAt": null}'
assert ModelUpdateRecord._is_early_access_active(version) is True
def test_normalize_paid_access_accepts_json_string():
"""The by-hash enrichment path may hand paidAccess to _normalize_paid_access
as a JSON string; both the permanent and timed shapes must normalize."""
@@ -841,14 +868,21 @@ def test_normalize_paid_access_accepts_json_string():
)
assert timed == {"permanent": False, "endsAt": "2026-08-22T18:30:00.000Z"}
empty = ModelUpdateService._normalize_paid_access(
# A timed gate whose window end is not recorded yet. CivitAI only returns a
# non-null DTO for an ACTIVE gate (tombstones come back as null) and enforces
# this shape too - the model page reports canDownload: false for it - so it
# must be kept. Dropping it was the #1060 class of bug.
pending_end = ModelUpdateService._normalize_paid_access(
'{"permanent": false, "endsAt": null}'
)
assert empty is None
assert pending_end == {"permanent": False, "endsAt": None}
malformed = ModelUpdateService._normalize_paid_access("{not json")
assert malformed is None
# No gate keys at all is not a gate signal.
assert ModelUpdateService._normalize_paid_access("{}") is None
def test_has_update_for_base_hide_paid():
"""hide_paid also suppresses permanent paid versions in the same-base
@@ -1059,3 +1093,811 @@ def test_build_record_from_remote_preserves_file_count(tmp_path):
timestamp=2.0,
)
assert record.versions[0].file_count == 1
# --- Gate-state transitions and price persistence ---------------------------
def _remote_gated(version_id, *, price_checked_at=None, price_buzz=None):
return ModelVersionRecord(
version_id=version_id,
name=f"v{version_id}",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
paid_access='{"permanent": false, "endsAt": "2026-10-10T13:10:17.404Z"}',
is_early_access=True,
early_access_ends_at="2026-10-10T13:10:17.404Z",
price_checked_at=price_checked_at,
price_buzz=price_buzz,
)
def _remote_free(version_id):
return ModelVersionRecord(
version_id=version_id,
name=f"v{version_id}",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
)
def test_build_record_emits_became_free_event(tmp_path):
"""A version whose gate lapsed produces a became_free event, keeps a lapse
timestamp, and drops the now-meaningless stored price."""
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(
replace(
_remote_gated(7, price_checked_at=100.0, price_buzz=500),
)
)
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[],
remote_versions=[_remote_free(7)],
existing=existing,
timestamp=1000.0,
)
version = record.versions[0]
assert version.gate_lapsed_at is not None
assert version.price_buzz is None
assert version.price_checked_at is None
assert [event["kind"] for event in record.events] == ["became_free"]
assert record.events[0]["versionId"] == 7
assert record.events[0]["isInLibrary"] is False
def test_build_record_emits_new_gate_event(tmp_path):
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(_remote_free(7))
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[],
remote_versions=[_remote_gated(7)],
existing=existing,
timestamp=1000.0,
)
assert [event["kind"] for event in record.events] == ["new_gate"]
assert record.versions[0].gate_lapsed_at is None
def test_build_record_no_events_without_previous_snapshot(tmp_path):
"""First sight of a model must not report every existing gate as a new one."""
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[],
remote_versions=[_remote_gated(7), _remote_free(8)],
existing=None,
timestamp=1000.0,
)
assert record.events == []
def test_build_record_keeps_lapse_marker_and_skips_ignored(tmp_path):
"""An already-free version keeps its original lapse marker and an ignored
version reports nothing at all."""
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
lapsed = replace(_remote_free(7), gate_lapsed_at="2026-09-01T00:00:00.000Z")
existing = make_record(lapsed, replace(_remote_gated(8), should_ignore=True))
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[],
remote_versions=[_remote_free(7), _remote_free(8)],
existing=existing,
timestamp=1000.0,
)
by_id = {version.version_id: version for version in record.versions}
assert by_id[7].gate_lapsed_at == "2026-09-01T00:00:00.000Z"
assert record.events == []
def test_build_record_preserves_price_when_fetch_skipped(tmp_path):
"""A refresh that did not run a price fetch (no price_checked_at) must keep the
stored price instead of wiping it."""
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(
replace(_remote_gated(7, price_checked_at=100.0, price_buzz=500))
)
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[],
remote_versions=[_remote_gated(7)],
existing=existing,
timestamp=1000.0,
)
assert record.versions[0].price_buzz == 500
assert record.versions[0].price_checked_at == 100.0
assert record.events == []
def test_build_record_applies_fresh_price(tmp_path):
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(
replace(_remote_gated(7, price_checked_at=100.0, price_buzz=500))
)
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[],
remote_versions=[_remote_gated(7, price_checked_at=200.0, price_buzz=250)],
existing=existing,
timestamp=1000.0,
)
assert record.versions[0].price_buzz == 250
assert record.versions[0].price_checked_at == 200.0
def _legacy_version_table_sql() -> str:
"""The model_update_versions schema before gate-lapse/price columns."""
return """
CREATE TABLE model_update_versions (
model_id INTEGER NOT NULL,
version_id INTEGER NOT NULL,
sort_index INTEGER NOT NULL DEFAULT 0,
name TEXT,
base_model TEXT,
released_at TEXT,
size_bytes INTEGER,
preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0,
early_access_ends_at TEXT,
is_early_access INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
file_count INTEGER,
PRIMARY KEY (model_id, version_id)
)
"""
def test_migration_adds_price_columns_to_legacy_db(tmp_path):
"""Opening a database written before this feature must add the columns and keep
the existing rows (the migration path real users hit)."""
db_path = tmp_path / "updates.sqlite"
conn = sqlite3.connect(db_path)
conn.execute(
"CREATE TABLE model_update_status ("
"model_id INTEGER PRIMARY KEY, model_type TEXT NOT NULL, "
"last_checked_at REAL, should_ignore_model INTEGER NOT NULL DEFAULT 0)"
)
conn.execute(_legacy_version_table_sql())
conn.execute(
"INSERT INTO model_update_status (model_id, model_type, last_checked_at) "
"VALUES (999, 'lora', 1.0)"
)
conn.execute(
"INSERT INTO model_update_versions ("
"model_id, version_id, is_in_library, is_early_access, paid_access, is_paid"
") VALUES (999, 7, 1, 1, '{\"permanent\": false, \"endsAt\": null}', 0)"
)
conn.commit()
conn.close()
service = ModelUpdateService(str(db_path))
columns = service._get_table_columns(
service._connect(), "model_update_versions"
)
for column in (
"gate_lapsed_at",
"price_buzz",
"list_price_buzz",
"generation_price_buzz",
"accepts_blue_buzz",
"price_sale_ends_at",
"price_checked_at",
"price_check_attempted_at",
):
assert column in columns
# The alert-state columns were removed together with the threshold they served.
assert "price_alert_state" not in columns
assert "price_alert_since" not in columns
record = service._get_record("lora", 999)
assert record is not None
assert [version.version_id for version in record.versions] == [7]
version = record.versions[0]
assert version.paid_access == '{"permanent": false, "endsAt": null}'
assert version.price_buzz is None
assert version.price_checked_at is None
assert version.gate_lapsed_at is None
assert version.accepts_blue_buzz is False
def test_price_fields_round_trip_through_sqlite(tmp_path):
"""Price and lapse columns survive an upsert/read cycle."""
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
version = replace(
_remote_gated(7, price_checked_at=123.5, price_buzz=500),
list_price_buzz=600,
generation_price_buzz=100,
accepts_blue_buzz=True,
price_sale_ends_at="2026-10-01T00:00:00.000Z",
is_in_library=True,
gate_lapsed_at=None,
)
service._upsert_record(make_record(version))
stored = service._get_record("lora", 999)
assert stored is not None
loaded = stored.versions[0]
assert loaded.price_buzz == 500
assert loaded.list_price_buzz == 600
assert loaded.generation_price_buzz == 100
assert loaded.accepts_blue_buzz is True
assert loaded.price_sale_ends_at == "2026-10-01T00:00:00.000Z"
assert loaded.price_checked_at == 123.5
assert loaded.price_check_attempted_at is None
# --- Optional price capture --------------------------------------------------
class FakeSettings:
"""Minimal stand-in for SettingsManager (only `.get` is used by these paths)."""
def __init__(self, values=None):
self._values = dict(values or {})
def get(self, key, default=None):
return self._values.get(key, default)
def set(self, key, value):
self._values[key] = value
class PriceProvider(DummyProvider):
"""DummyProvider that can also serve prices."""
def __init__(self, response, *, prices=None, error=None):
super().__init__(response)
self.prices = prices
self.error = error
self.price_calls = 0
async def get_model_prices(self, model_id):
self.price_calls += 1
if self.error is not None:
raise self.error
return self.prices
GATED_RESPONSE = {
"modelVersions": [
{
"id": 12,
"baseModel": "Pony",
"availability": "Public",
"paidAccess": {"permanent": False, "endsAt": "2999-01-01T00:00:00.000Z"},
"files": [],
"images": [],
}
]
}
FREE_RESPONSE = {
"modelVersions": [
{"id": 12, "baseModel": "Pony", "availability": "Public", "files": [], "images": []}
]
}
LOCAL_RAW_DATA = [{"civitai": {"modelId": 1, "id": 11}, "base_model": "Pony"}]
PRICE_PAYLOAD = {
12: {
"price_buzz": 250,
"list_price_buzz": 500,
"generation_price_buzz": 100,
"accepts_blue_buzz": True,
"price_sale_ends_at": "2999-01-02T00:00:00.000Z",
}
}
def _price_service(tmp_path, **settings):
return ModelUpdateService(
str(tmp_path / "updates.sqlite"),
ttl_seconds=0,
settings_manager=FakeSettings(settings),
)
@pytest.mark.asyncio
async def test_price_capture_off_by_default(tmp_path):
service = _price_service(tmp_path)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
record = await service.get_record("lora", 1)
assert provider.price_calls == 0
assert record.versions[0].price_checked_at is None
assert record.versions[0].price_buzz is None
@pytest.mark.asyncio
async def test_price_capture_stores_prices_for_gated_versions(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
record = await service.get_record("lora", 1)
assert provider.price_calls == 1
version = next(v for v in record.versions if v.version_id == 12)
assert version.price_buzz == 250
assert version.list_price_buzz == 500
assert version.generation_price_buzz == 100
assert version.accepts_blue_buzz is True
assert version.price_sale_ends_at == "2999-01-02T00:00:00.000Z"
assert version.price_checked_at is not None
@pytest.mark.asyncio
async def test_price_capture_skips_ungated_models(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(FREE_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 0
@pytest.mark.asyncio
async def test_price_capture_failure_keeps_stored_price(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
)
first = await service.get_record("lora", 1)
stored_checked_at = next(
v for v in first.versions if v.version_id == 12
).price_checked_at
assert stored_checked_at is not None
# The page is unreadable this time (a changed gate still triggers a fetch):
# the price must survive untouched rather than being blanked.
failing = PriceProvider(
{
"modelVersions": [
{
"id": 12,
"baseModel": "Pony",
"availability": "Public",
"paidAccess": {
"permanent": False,
"endsAt": "2999-06-01T00:00:00.000Z",
},
"files": [],
"images": [],
}
]
},
prices=None,
)
await service.refresh_for_model_type("lora", scanner, failing)
record = await service.get_record("lora", 1)
version = next(v for v in record.versions if v.version_id == 12)
assert failing.price_calls == 1
assert version.price_buzz == 250
assert version.price_checked_at == stored_checked_at
@pytest.mark.asyncio
async def test_price_capture_respects_ttl(tmp_path):
"""A second refresh with an unchanged gate and a fresh price must not refetch."""
service = _price_service(
tmp_path, price_tracking_enabled=True, price_check_ttl_hours=24
)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 1
@pytest.mark.asyncio
async def test_price_capture_refetches_when_gate_changes(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
changed_gate = {
"modelVersions": [
{
"id": 12,
"baseModel": "Pony",
"availability": "Public",
"paidAccess": {
"permanent": True,
"endsAt": None,
},
"files": [],
"images": [],
}
]
}
provider.response = changed_gate
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 2
@pytest.mark.asyncio
async def test_price_capture_survives_provider_without_support(tmp_path):
"""A provider that cannot serve prices must not break the refresh."""
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = DummyProvider(GATED_RESPONSE)
await service.refresh_for_model_type("lora", scanner, provider)
record = await service.get_record("lora", 1)
assert record is not None
version = next(v for v in record.versions if v.version_id == 12)
assert version.price_buzz is None
@pytest.mark.asyncio
async def test_price_capture_drops_price_when_version_becomes_free(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
)
await service.refresh_for_model_type("lora", scanner, PriceProvider(FREE_RESPONSE))
record = await service.get_record("lora", 1)
version = next(v for v in record.versions if v.version_id == 12)
assert version.price_buzz is None
assert version.price_checked_at is None
assert version.gate_lapsed_at is not None
# --- Price alerts ------------------------------------------------------------
def _gated_response(ends_at: str) -> dict:
"""Gated response with a specific EA end.
Varying the end date between refreshes is what forces a re-fetch inside a
single test (a price is otherwise considered fresh for the whole TTL).
"""
return {
"modelVersions": [
{
"id": 12,
"baseModel": "Pony",
"availability": "Public",
"paidAccess": {"permanent": False, "endsAt": ends_at},
"files": [],
"images": [],
}
]
}
def _prices(price_buzz: int) -> dict:
return {12: {"price_buzz": price_buzz, "list_price_buzz": price_buzz}}
# --- Price alerts panel semantics (read-time threshold, across types) ---------
def _gated_response_for(version_id: int, ends_at: str) -> dict:
return {
"modelVersions": [
{
"id": version_id,
"baseModel": "Pony",
"availability": "Public",
"paidAccess": {"permanent": False, "endsAt": ends_at},
"files": [],
"images": [],
}
]
}
def _free_response_for(version_id: int) -> dict:
return {
"modelVersions": [
{
"id": version_id,
"baseModel": "Pony",
"availability": "Public",
"files": [],
"images": [],
}
]
}
@pytest.mark.asyncio
async def test_failed_price_attempt_is_recorded_as_unavailable(tmp_path):
"""A gated version we tried to price and could not must be distinguishable
from one we never looked at — that is the honest "unavailable" state for
mature models, whose pages no host will serve anonymously."""
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
failing = PriceProvider(GATED_RESPONSE, prices=None)
await service.refresh_for_model_type("lora", scanner, failing)
record = await service.get_record("lora", 1)
version = next(v for v in record.versions if v.version_id == 12)
assert failing.price_calls == 1
assert version.price_buzz is None
assert version.price_checked_at is None
assert version.price_check_attempted_at is not None
@pytest.mark.asyncio
async def test_successful_price_attempt_sets_both_markers(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
)
record = await service.get_record("lora", 1)
version = next(v for v in record.versions if v.version_id == 12)
assert version.price_buzz == 250
assert version.price_checked_at is not None
assert version.price_check_attempted_at is not None
@pytest.mark.asyncio
async def test_no_price_attempt_is_recorded_while_tracking_is_off(tmp_path):
service = _price_service(tmp_path) # tracking off
scanner = DummyScanner(LOCAL_RAW_DATA)
await service.refresh_for_model_type("lora", scanner, DummyProvider(GATED_RESPONSE))
record = await service.get_record("lora", 1)
assert record.versions[0].price_check_attempted_at is None
@pytest.mark.asyncio
async def test_unavailable_marker_clears_when_the_version_becomes_free(tmp_path):
service = _price_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
await service.refresh_for_model_type(
"lora", scanner, PriceProvider(GATED_RESPONSE, prices=None)
)
await service.refresh_for_model_type("lora", scanner, DummyProvider(FREE_RESPONSE))
record = await service.get_record("lora", 1)
assert record.versions[0].price_check_attempted_at is None
def _long_ttl_service(tmp_path, **settings):
"""A service whose metadata TTL does not lapse during the test."""
return ModelUpdateService(
str(tmp_path / "updates.sqlite"),
ttl_seconds=86400,
settings_manager=FakeSettings(settings),
)
@pytest.mark.asyncio
async def test_prices_are_fetched_even_when_the_version_list_is_fresh(tmp_path):
"""Enabling price tracking must not wait for the metadata TTL.
The cached record already carries the gate, so a fresh version list is no
reason to skip the price: otherwise turning the feature on prices only the
handful of models that happened to need a metadata refresh that round.
"""
service = _long_ttl_service(tmp_path, price_tracking_enabled=False)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 0
metadata_calls = provider.calls
checked_at_before = (await service.get_record("lora", 1)).last_checked_at
service._settings.set("price_tracking_enabled", True)
await service.refresh_for_model_type("lora", scanner, provider)
record = await service.get_record("lora", 1)
version = next(v for v in record.versions if v.version_id == 12)
# The version list came from the cache this round ...
assert provider.calls == metadata_calls
# ... and the price was captured anyway.
assert provider.price_calls == 1
assert version.price_buzz == 250
assert version.price_checked_at is not None
assert version.price_check_attempted_at is not None
# A price-only pass must not extend the metadata TTL.
assert record.last_checked_at == checked_at_before
@pytest.mark.asyncio
async def test_failed_price_attempt_is_not_retried_within_the_ttl(tmp_path):
"""A mature model whose page no host will serve must not cost two requests
on every single update check."""
service = _long_ttl_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=None)
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 1
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 1
@pytest.mark.asyncio
async def test_forced_refresh_reprices_within_the_ttl(tmp_path):
service = _long_ttl_service(tmp_path, price_tracking_enabled=True)
scanner = DummyScanner(LOCAL_RAW_DATA)
provider = PriceProvider(GATED_RESPONSE, prices=PRICE_PAYLOAD)
await service.refresh_for_model_type("lora", scanner, provider)
assert provider.price_calls == 1
await service.refresh_for_model_type(
"lora", scanner, provider, force_refresh=True
)
assert provider.price_calls == 2
# --- Ownership scoping and the removed threshold -----------------------------
def test_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path):
"""A version already on disk cannot become cheaper *for this user*, so it must
not produce an event. More than half of a real library's gated versions are
owned, so this is the common case, not an edge case."""
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(replace(_remote_gated(7), is_in_library=True))
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[7],
remote_versions=[_remote_free(7)],
existing=existing,
timestamp=1000.0,
)
assert record.events == []
# The lapse itself is still recorded - it is the *notification* that is
# pointless for a version the user already has.
assert record.versions[0].gate_lapsed_at is not None
def test_new_gate_event_is_suppressed_for_a_version_the_user_owns(tmp_path):
service = ModelUpdateService(str(tmp_path / "updates.sqlite"))
existing = make_record(replace(_remote_free(7), is_in_library=True))
record = service._build_record_from_remote(
model_type="lora",
model_id=999,
local_versions=[7],
remote_versions=[_remote_gated(7)],
existing=existing,
timestamp=1000.0,
)
assert record.events == []
def test_threshold_setting_is_gone_from_the_defaults():
"""The redesign removed the numeric threshold: every obtainability decision is
categorical (wait / pay / skip), so no number is stored or compared."""
from py.services.settings_manager import DEFAULT_SETTINGS
assert "price_alert_threshold_buzz" not in DEFAULT_SETTINGS
assert "price_tracking_enabled" in DEFAULT_SETTINGS
assert "price_check_ttl_hours" in DEFAULT_SETTINGS
def test_obsolete_alert_columns_are_dropped_from_a_development_database(tmp_path):
"""A database created while the feature was unreleased still carries the two
alert-state columns; opening it must drop them rather than leave dead schema."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
with service._connect() as conn:
conn.execute(
"ALTER TABLE model_update_versions "
"ADD COLUMN price_alert_state INTEGER NOT NULL DEFAULT 0"
)
conn.execute(
"ALTER TABLE model_update_versions ADD COLUMN price_alert_since REAL"
)
conn.commit()
assert "price_alert_state" in service._get_table_columns(
conn, "model_update_versions"
)
# A fresh service instance runs the migration on open.
reopened = ModelUpdateService(str(db_path))
columns = reopened._get_table_columns(
reopened._connect(), "model_update_versions"
)
assert "price_alert_state" not in columns
assert "price_alert_since" not in columns
+53
View File
@@ -1,6 +1,7 @@
import asyncio
import copy
import json
import logging
import os
from concurrent.futures import Future
@@ -133,6 +134,58 @@ def test_existing_folder_paths_seed_default_library(tmp_path, monkeypatch):
assert manager.get_startup_messages() == []
def test_multi_library_configuration_triggers_deprecation_warning(
tmp_path, monkeypatch, caplog
):
initial = {
"libraries": {
"default": {"folder_paths": {"loras": ["/loras-a"]}},
"archive": {"folder_paths": {"loras": ["/loras-b"]}},
},
"active_library": "default",
}
with caplog.at_level(logging.WARNING, logger="py.services.settings_manager"):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
assert any(
"Multi-library support is deprecated" in record.getMessage()
for record in caplog.records
)
deprecation = [
message
for message in manager.get_startup_messages()
if message["code"] == "multi-library-deprecated"
]
assert len(deprecation) == 1
assert deprecation[0]["severity"] == "warning"
assert deprecation[0]["dismissible"] is True
assert "archive" in deprecation[0]["message"]
def test_single_library_configuration_has_no_deprecation_warning(
tmp_path, monkeypatch, caplog
):
initial = {
"libraries": {
"default": {"folder_paths": {"loras": ["/loras-a"]}},
},
"active_library": "default",
}
with caplog.at_level(logging.WARNING, logger="py.services.settings_manager"):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
assert not any(
"Multi-library support is deprecated" in record.getMessage()
for record in caplog.records
)
assert not any(
message["code"] == "multi-library-deprecated"
for message in manager.get_startup_messages()
)
def test_environment_variable_overrides_settings(tmp_path, monkeypatch):
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
monkeypatch.setenv("CIVITAI_API_KEY", "secret")
@@ -402,3 +402,138 @@ async def test_model_without_hash_skipped(use_case, mock_service, mock_metadata_
assert result["processed"] == 1
assert result["updated"] == 0
def _offline_scanner_mock(scanner):
"""Point the scanner mock at one offline and one online root."""
scanner.describe_model_roots = MagicMock(
return_value=[
{"path": "/mnt/offline_loras", "label": "offline_loras", "reachable": False, "available": False, "models": 1},
{"path": "/models", "label": "models", "reachable": True, "available": True, "models": 1},
]
)
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_fetch_skips_models_on_offline_root(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Models under an unreachable root are excluded from the fetch queue."""
mock_hydrate.return_value = None
_offline_scanner_mock(mock_service.scanner)
offline_model = {
"file_path": "/mnt/offline_loras/model.safetensors",
"sha256": "hash_offline",
"model_name": "Offline Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
online_model = {
"file_path": "/models/model.safetensors",
"sha256": "hash_online",
"model_name": "Online Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[offline_model, online_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
reporter = MockProgressReporter()
result = await use_case.execute(progress_callback=reporter)
# Only the online model is fetched
mock_metadata_sync.fetch_and_update_model.assert_called_once()
call_args = mock_metadata_sync.fetch_and_update_model.call_args[1]
assert call_args["file_path"] == "/models/model.safetensors"
assert result["success"] is True
assert result["processed"] == 1
assert result["offline_skipped"] == 1
# The offline model still counts as excluded from the queue
assert result["skipped_count"] == 1
completed_calls = [c for c in reporter.progress_calls if c["status"] == "completed"]
assert completed_calls[0]["offline_skipped"] == 1
assert completed_calls[0]["offline_roots"] == [
{"path": "/mnt/offline_loras", "label": "offline_loras"}
]
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_fetch_offline_filter_only_catches_offline_prefix(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""A sibling path that merely shares a prefix string is not excluded."""
mock_hydrate.return_value = None
_offline_scanner_mock(mock_service.scanner)
# "/mnt/offline_loras_backup/..." must NOT match the "/mnt/offline_loras/" prefix
sibling_model = {
"file_path": "/mnt/offline_loras_backup/model.safetensors",
"sha256": "hash_sibling",
"model_name": "Sibling Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[sibling_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
result = await use_case.execute()
mock_metadata_sync.fetch_and_update_model.assert_called_once()
assert result["offline_skipped"] == 0
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_fetch_proceeds_unfiltered_without_root_description(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Scanners without describe_model_roots keep the previous behaviour."""
mock_hydrate.return_value = None
del mock_service.scanner.describe_model_roots
model = {
"file_path": "/mnt/offline_loras/model.safetensors",
"sha256": "hash123",
"model_name": "Test Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
result = await use_case.execute()
mock_metadata_sync.fetch_and_update_model.assert_called_once()
assert result["offline_skipped"] == 0
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_fetch_proceeds_unfiltered_when_root_description_fails(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""A failing describe_model_roots must not break the fetch."""
mock_hydrate.return_value = None
mock_service.scanner.describe_model_roots = MagicMock(side_effect=RuntimeError("boom"))
model = {
"file_path": "/models/model.safetensors",
"sha256": "hash123",
"model_name": "Test Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
result = await use_case.execute()
mock_metadata_sync.fetch_and_update_model.assert_called_once()
assert result["success"] is True
assert result["offline_skipped"] == 0
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<!--
Trimmed stand-in for a CivitAI model page, mirroring the shape verified against
civitai.com / civitai.red. Values are synthetic; the structure (unrelated query
first, then the model.getById query, tombstones included) is what matters.
-->
<html>
<head>
<title>Fixture model</title>
</head>
<body>
<script id="__NEXT_DATA__" type="application/json">
{
"props": {
"pageProps": {
"trpcState": {
"json": {
"queries": [
{
"queryKey": [["common", "getEntityAccess"], { "entityId": 1001 }],
"state": { "data": [] }
},
{
"queryKey": [
["model", "getById"],
{ "input": { "id": 4242, "browsingLevel": 1 }, "type": "query" }
],
"state": {
"data": {
"id": 4242,
"name": "Fixture model",
"modelVersions": [
{
"id": 1001,
"name": "permanent paid",
"canDownload": false,
"paidAccess": {
"endsAt": null,
"timeframeDays": null,
"terms": {
"download": { "price": 5000 },
"generation": { "price": 100, "trialLimit": 5 }
},
"sale": null
}
},
{
"id": 1002,
"name": "timed gate on sale",
"canDownload": false,
"paidAccess": {
"endsAt": "2999-10-10T13:10:17.404Z",
"timeframeDays": 7,
"terms": {
"download": { "price": 125 },
"generation": { "trialLimit": 10 },
"acceptsBlueBuzz": true
},
"sale": {
"listTerms": { "download": { "price": 125 } },
"buyerTerms": { "download": { "price": 100 } },
"endsAt": "2999-09-01T00:00:00.000Z",
"discountType": "percent",
"discountAmount": 20
}
}
},
{
"id": 1003,
"name": "timed gate with no recorded end",
"canDownload": false,
"paidAccess": {
"endsAt": null,
"timeframeDays": 7,
"terms": { "download": { "price": 300 } },
"sale": null
}
},
{
"id": 1004,
"name": "lapsed gate (tombstone)",
"canDownload": true,
"paidAccess": {
"endsAt": "2000-08-27T16:56:57.438Z",
"timeframeDays": 3,
"terms": { "download": { "price": 200 } },
"sale": null
}
},
{
"id": 1005,
"name": "never gated",
"canDownload": true,
"paidAccess": null
}
]
}
}
}
]
}
}
}
}
}
</script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
"""Tests for the CivitAI model page price parser.
The fixture mirrors a real page payload; the cases that matter are the ones that
decide whether a user gets a price at all, and whether a lapsed gate is mistaken
for a live one.
"""
from pathlib import Path
import pytest
from py.utils.civitai_page_prices import MAX_PAGE_BYTES, parse_model_page_prices
FIXTURE = Path(__file__).parent / "fixtures" / "civitai_model_page_paid.html"
@pytest.fixture(scope="module")
def fixture_html() -> str:
return FIXTURE.read_text(encoding="utf-8")
def test_parses_prices_for_gated_versions(fixture_html):
prices = parse_model_page_prices(fixture_html)
assert prices is not None
# Lapsed gates and never-gated versions must not appear.
assert set(prices) == {1001, 1002, 1003}
def test_permanent_gate_price(fixture_html):
prices = parse_model_page_prices(fixture_html)
permanent = prices[1001]
assert permanent["price_buzz"] == 5000
assert permanent["list_price_buzz"] == 5000
assert permanent["generation_price_buzz"] == 100
assert permanent["accepts_blue_buzz"] is False
assert permanent["price_sale_ends_at"] is None
def test_timed_gate_uses_sale_price_as_effective(fixture_html):
prices = parse_model_page_prices(fixture_html)
timed = prices[1002]
# The stored/list price stays visible for a strikethrough, the effective price
# is what a buyer pays now.
assert timed["price_buzz"] == 100
assert timed["list_price_buzz"] == 125
assert timed["accepts_blue_buzz"] is True
assert timed["price_sale_ends_at"] == "2999-09-01T00:00:00.000Z"
# Generation is bundled with the download tier here (`trialLimit` only).
assert timed["generation_price_buzz"] is None
def test_timed_gate_without_recorded_end_is_still_priced(fixture_html):
prices = parse_model_page_prices(fixture_html)
assert prices[1003]["price_buzz"] == 300
assert prices[1003]["generation_price_buzz"] is None
@pytest.mark.parametrize(
"html",
[
None,
"",
"<html><body>no payload here</body></html>",
'<script id="__NEXT_DATA__" type="application/json">{not json</script>',
# Valid JSON, but not the shape we need.
'<script id="__NEXT_DATA__" type="application/json">{"props":{}}</script>',
# Model query present, but no versions.
'<script id="__NEXT_DATA__" type="application/json">'
'{"props":{"pageProps":{"trpcState":{"json":{"queries":['
'{"queryKey":[["model","getById"]],"state":{"data":{}}}]}}}}}</script>',
],
)
def test_unusable_payloads_return_none(html):
assert parse_model_page_prices(html) is None
def test_oversized_page_is_skipped():
oversized = "x" * (MAX_PAGE_BYTES + 1)
assert parse_model_page_prices(oversized) is None
def test_unrelated_first_query_does_not_win(fixture_html):
"""Query order varies per page; selection is by procedure name."""
prices = parse_model_page_prices(fixture_html)
assert prices is not None
assert 1001 in prices
+22
View File
@@ -1,3 +1,5 @@
import pytest
from py.utils.civitai_utils import (
build_license_flags,
extract_civitai_image_id,
@@ -9,6 +11,26 @@ from py.utils.civitai_utils import (
)
@pytest.mark.parametrize("host", ["civitai.com", "civitai.red"])
@pytest.mark.parametrize(
"path",
["/api/download/models/3390681", "/api/v1/models/3390681", "/models/3390681invalid"],
)
def test_extract_civitai_model_url_parts_rejects_non_model_pages(host, path):
assert extract_civitai_model_url_parts(
f"https://{host}{path}?modelVersionId=3390681"
) == (None, None)
@pytest.mark.parametrize("host", ["civitai.com", "civitai.red"])
@pytest.mark.parametrize(
"path",
["/api/v1/images/126920345", "/images/126920345invalid"],
)
def test_extract_civitai_image_id_rejects_non_image_pages(host, path):
assert extract_civitai_image_id(f"https://{host}{path}") is None
def test_resolve_license_payload_defaults():
payload, flags = resolve_license_info({})
+112
View File
@@ -0,0 +1,112 @@
"""Tests for the shared CivitAI paidAccess interpretation.
These rules decide both the Paid/Early Access badges and whether a download is
warned about, so the interesting cases are the boundary shapes CivitAI actually
emits.
"""
from datetime import datetime, timezone
import pytest
from py.utils.paid_access import (
is_early_access_deadline_active,
is_gate_active,
is_permanent_paid,
normalize_paid_access,
parse_civitai_timestamp,
)
NOW = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
@pytest.mark.parametrize(
"value,expected",
[
(None, None),
("", None),
("{not json", None),
("[1, 2]", None),
({}, None),
({"unknown": 1}, None),
# A non-null DTO from CivitAI's public API is always an active gate, so a
# timed gate with no recorded end must survive normalization.
({"permanent": False, "endsAt": None}, {"permanent": False, "endsAt": None}),
({"permanent": True, "endsAt": None}, {"permanent": True, "endsAt": None}),
(
{"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"},
{"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"},
),
],
)
def test_normalize_paid_access_shapes(value, expected):
assert normalize_paid_access(value) == expected
def test_normalize_paid_access_accepts_json_text():
"""The by-hash enrichment path can carry the DTO as a JSON string."""
assert normalize_paid_access('{"permanent": true, "endsAt": null}') == {
"permanent": True,
"endsAt": None,
}
def test_normalize_paid_access_blank_ends_at_is_none():
assert normalize_paid_access({"permanent": False, "endsAt": " "}) == {
"permanent": False,
"endsAt": None,
}
def test_is_permanent_paid():
assert is_permanent_paid({"permanent": True, "endsAt": None}) is True
assert is_permanent_paid({"permanent": False, "endsAt": None}) is False
assert is_permanent_paid(None) is False
@pytest.mark.parametrize(
"paid_access,expected",
[
(None, False),
({"permanent": False, "endsAt": None}, True),
({"permanent": True, "endsAt": None}, True),
# A permanent gate with a stale endsAt stays active.
({"permanent": True, "endsAt": "2020-01-01T00:00:00.000Z"}, True),
({"permanent": False, "endsAt": "2026-10-10T13:10:17.404Z"}, True),
({"permanent": False, "endsAt": "2026-08-27T16:56:57.438Z"}, False),
({"permanent": False, "endsAt": "not-a-date"}, True),
],
)
def test_is_gate_active(paid_access, expected):
assert is_gate_active(paid_access, now=NOW) is expected
@pytest.mark.parametrize(
"ends_at,expected",
[
(None, False),
("", False),
("2026-10-10T13:10:17.404Z", True),
("2026-08-27T16:56:57.438Z", False),
("garbage", True),
],
)
def test_is_early_access_deadline_active(ends_at, expected):
assert is_early_access_deadline_active(ends_at, now=NOW) is expected
@pytest.mark.parametrize(
"value,expected",
[
(None, None),
(123, None),
("", None),
("garbage", None),
("2026-10-10T13:10:17.404Z", datetime(2026, 10, 10, 13, 10, 17, 404000, tzinfo=timezone.utc)),
# Naive timestamps are assumed UTC.
("2026-10-10T13:10:17", datetime(2026, 10, 10, 13, 10, 17, tzinfo=timezone.utc)),
],
)
def test_parse_civitai_timestamp(value, expected):
assert parse_civitai_timestamp(value) == expected