mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-09 02:52:12 -03:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a6b0f78a3 | ||
|
|
18f3208468 | ||
|
|
01ffda7303 | ||
|
|
fad17decdf | ||
|
|
acefe1a348 | ||
|
|
95acd6308a | ||
|
|
3468686e5f | ||
|
|
842ffb1df8 | ||
|
|
1805519f4f | ||
|
|
f4d0228d6b | ||
|
|
08a8e54d87 | ||
|
|
0941f99226 | ||
|
|
12930ce78c | ||
|
|
bd184559bf | ||
|
|
470d85cca6 | ||
|
|
2dcaf6a30e | ||
|
|
5ebf5aeccc | ||
|
|
a48855d576 | ||
|
|
14e660e876 | ||
|
|
8cd53c20f8 | ||
|
|
7e57f12796 | ||
|
|
0e3897f09b | ||
|
|
db50632e83 | ||
|
|
d141c29ced | ||
|
|
dcc0add6b0 | ||
|
|
0c5bf329e9 | ||
|
|
fbd2e6a2eb | ||
|
|
15dda1f168 | ||
|
|
977bbffa67 | ||
|
|
98ba319735 | ||
|
|
cbe9d58522 | ||
|
|
e42d649df0 | ||
|
|
156d2e5eb9 | ||
|
|
425d90a912 | ||
|
|
ec5fef512b | ||
|
|
7ed19c185c | ||
|
|
ad2402724b | ||
|
|
5f4054265d |
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,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"""
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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}). "
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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)]
|
||||
@@ -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("\\", "/")
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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", {})})
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
] == []
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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({})
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user