mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-11 12:02:12 -03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f71d524a4 | ||
|
|
d5139500f9 | ||
|
|
79db86666d | ||
|
|
cbbefdecf9 | ||
|
|
c0165919b3 | ||
|
|
3d165f92e0 | ||
|
|
a8941f453f | ||
|
|
5796a0e2e9 | ||
|
|
6d45f9296b | ||
|
|
377a1337db | ||
|
|
e1b102fc64 | ||
|
|
587d952604 | ||
|
|
b31e831c57 | ||
|
|
af90fe873d | ||
|
|
7937aeaa04 | ||
|
|
406096c0e1 | ||
|
|
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 | ||
|
|
9dacaf06f7 | ||
|
|
977bbffa67 | ||
|
|
98ba319735 | ||
|
|
cbe9d58522 | ||
|
|
e42d649df0 | ||
|
|
156d2e5eb9 | ||
|
|
425d90a912 | ||
|
|
ec5fef512b | ||
|
|
7ed19c185c | ||
|
|
ad2402724b | ||
|
|
5f4054265d | ||
|
|
f94c6b7497 | ||
|
|
cf3bff9922 | ||
|
|
43ef1e86f6 | ||
|
|
35f1ced41a | ||
|
|
b36f2099ee | ||
|
|
896ce5eddb | ||
|
|
f8aba393fe | ||
|
|
515469054c | ||
|
|
a5bd3845da | ||
|
|
034660d8c4 | ||
|
|
2a667df98c | ||
|
|
693e7e8dca | ||
|
|
4ece4b42ff | ||
|
|
7b2a108596 | ||
|
|
3aa32120df | ||
|
|
2193ec8f38 | ||
|
|
eeb9270827 | ||
|
|
42fa8294df | ||
|
|
18504c712c | ||
|
|
013c378a7a | ||
|
|
faeb66a23d | ||
|
|
69691b17a1 | ||
|
|
b90d60f043 | ||
|
|
c3a9350155 | ||
|
|
0dd8d74032 | ||
|
|
0a262cbe0c | ||
|
|
ebd2b5b3e0 | ||
|
|
0f7aa85b0b | ||
|
|
e7c1c07db0 | ||
|
|
485679223b | ||
|
|
7aee964448 | ||
|
|
62c144d80a | ||
|
|
d21f209bad | ||
|
|
a6fca8612f | ||
|
|
5e4462822d | ||
|
|
6e45ef566c | ||
|
|
16430aef21 | ||
|
|
f5e983eaaa | ||
|
|
297d8787bd | ||
|
|
20d8c22390 |
@@ -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
|
||||
@@ -10,6 +10,9 @@ civitai/
|
||||
stats/
|
||||
wildcards/
|
||||
backups/
|
||||
# Portable-mode centralized sidecar storage (<repo>/sidecars): user data that
|
||||
# must survive pulls and stay out of git status
|
||||
/sidecars/
|
||||
logs/
|
||||
node_modules/
|
||||
coverage/
|
||||
|
||||
@@ -266,6 +266,13 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
||||
them during migration/import. Never write, modify, or delete them, and never
|
||||
propose doing so as a fix — LoRA Manager's own metadata lives in the
|
||||
`.metadata.json` sidecar it owns.
|
||||
- **Sidecar/preview path derivation must go through `py/utils/sidecar_paths.py`**
|
||||
helpers (never inline `splitext + ".metadata.json"`): the centralized storage
|
||||
mode (`sidecar_storage_mode` / `sidecar_storage_path` settings) relocates
|
||||
`.metadata.json` files and preview images under a mirror tree, so any
|
||||
hand-built path is wrong in that mode. `.civitai.info` stays co-located with
|
||||
the model file in both modes. The new settings keys live only in
|
||||
`DEFAULT_SETTINGS` — `settings.json.example` stays minimal (see below).
|
||||
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
|
||||
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
|
||||
|
||||
+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
|
||||
|
||||
+311
-294
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,7 @@ Enriches models linked to an external model site with metadata extracted by an L
|
||||
| ModelScope (`modelscope.cn`) | yes | yes | yes |
|
||||
| ModelScope International (`modelscope.ai`) | yes | yes | yes |
|
||||
| TensorArt | yes | no (see below) | no |
|
||||
| OpenModelDB | yes | yes (card data from the catalogue, no README) | yes |
|
||||
|
||||
`modelscope.cn` and `modelscope.ai` are **separate catalogues, not mirrors** — a
|
||||
repository published on one is routinely absent from the other — so each is
|
||||
@@ -85,6 +86,8 @@ tables in `modelSourceHelpers.js` and `registry.py` in step when adding a site.
|
||||
|
||||
TensorArt is link-only: `tensor.art` sits behind a Cloudflare managed challenge and its internal API requires session authorization, so the backend cannot read its model pages. Linking still stores the canonical page URL and the "View on TensorArt" link works.
|
||||
|
||||
OpenModelDB is the upscaler catalogue: model ids are flat tokens (no `owner/name`), there are no revisions and no README — `fetch_model_card_context()` reads everything (description, license, tags, example images) from the disk-cached bulk catalogue in `py/services/openmodeldb_client.py`. Only PyTorch resources (`.pth`/`.safetensors`) are downloadable, and only via mirrors that serve raw bytes: HTML-gateway hosts (`mediafire.com`, `mega.nz`, `drive.google.com`) are skipped in favour of a direct mirror, and a model with only gateway mirrors reports a manual-download hint instead of a file list. Filenames are derived from URL path segments (mediafire buries them mid-path) or synthesized as `{model_id}.{type}` for folder links.
|
||||
|
||||
**What it does**:
|
||||
1. Reads the model's `.metadata.json` to get the source (`source_platform` + `source_url`, or the legacy `hf_url`)
|
||||
2. Fetches the model card through the provider in `py/services/model_sources/` — the README via `fetch_model_card()`, plus any extras the site keeps outside it via `fetch_model_card_context()`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||
`locales/`.
|
||||
|
||||
Source of truth: `locales/en.json` (10 locales, 2025 leaf keys; all locales share the exact
|
||||
Source of truth: `locales/en.json` (10 locales, 2128 leaf keys; all locales share the exact
|
||||
same key structure).
|
||||
|
||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
@@ -78,6 +78,131 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
|
||||
> locales are translated (terminology in §2, "Filename Templates feature").
|
||||
|
||||
> **Status (2026-09, folder delete verification):** the folder delete modal no longer trusts the
|
||||
> sidebar's "empty folder" prediction — it dry-runs the delete against the backend and renders
|
||||
> the answer, so a folder whose models are all *excluded* (invisible to the model lists, still
|
||||
> real weight files on disk) is refused with an explanation instead of contradicting itself.
|
||||
> That added 5 keys (`sidebar.deleteFolderModal.notEmptyMessageCount`,
|
||||
> `.notEmptyMessageExcluded`, `.busyTitle`, `.checking`, `sidebar.deleteFolderResult.notEmptyWithCount`);
|
||||
> all 9 locales are translated (terminology in §2, "Folder sidebar feature"), so the
|
||||
> "no remaining placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-09, sidecar storage):** optional centralized storage for `.metadata.json`
|
||||
> sidecars and preview images added 23 keys — `settings.sections.sidecarStorage`,
|
||||
> the 18 `settings.sidecarStorage.*` labels/help/status/confirm strings, and the 4
|
||||
> `modals.sidecarMigrationConfirm.*` titles/button. The pull request merged them as
|
||||
> `[TODO: Translate]` copies; all 9 locales are now translated (terminology in §2,
|
||||
> "Sidecar storage feature"), so no placeholder remains and the "no remaining placeholders"
|
||||
> claim holds again.
|
||||
|
||||
> **Status (2026-09, sidecar storage UX follow-up):** the migration UX follow-up added
|
||||
> 7 `settings.sidecarStorage.open*`/path-display keys, `modals.sidecarMigrationConfirm.destination`,
|
||||
> and the 13-key `modals.sidecarMigrationResult.*` summary block (which replaces
|
||||
> `settings.sidecarStorage.migrateSuccess` — the result modal is now the success feedback,
|
||||
> mirroring `modals.metadataFetchSummary.*`/`modals.downloadBatchSummary.*` stat-card and
|
||||
> failure-table conventions; reuse each locale's existing renderings of those sibling keys).
|
||||
> All 9 locales are translated in the same pass (terminology in §2, "Sidecar storage feature").
|
||||
|
||||
> **Status (2026-10, unknown base model routing):** the download-routing inversion added
|
||||
> 4 keys (`settings.unknownBaseModelRouting.label`, `.help`, `.options.diffusionModel`,
|
||||
> `.options.checkpoint`) — the option labels reuse each locale's existing
|
||||
> `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings. The same pass also
|
||||
> translated the leftover `doctor.issues.sidecar_mirror_orphans.title` ("Centralized
|
||||
> Sidecars", §2 "Sidecar storage feature" terminology). All 9 locales are translated,
|
||||
> so the "no remaining placeholders" claim holds again. Terminology in §2, "Download
|
||||
> routing feature".
|
||||
|
||||
> **Status (2026-10, routing-override follow-up):** the download modal's location step
|
||||
> gained a manual "Destination type" toggle (Checkpoint | Diffusion Model) for when the
|
||||
> auto routing misdetects, adding 2 keys (`modals.download.routingOverride.label`,
|
||||
> `.tooltip`). The tooltip quotes each locale's `modals.download.useDefaultPath` label
|
||||
> verbatim (switching turns it off for the session), using that locale's UI-label quoting
|
||||
> style. All 9 locales are translated (terminology in §2, "Download routing feature"),
|
||||
> so the "no remaining placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-10, OpenModelDB):** the OpenModelDB metadata-provider toggle added 2 keys
|
||||
> (`settings.metadataArchive.enableOpenmodeldbApi(Help)`); all 9 locales are translated
|
||||
> (terminology in §2, "OpenModelDB feature"), so the "no remaining placeholders" claim
|
||||
> holds again.
|
||||
|
||||
> **Status (2026-10, Civitai ids in model modal):** the model modal's hash footnote now
|
||||
> shows the Civitai model id and version id (right-aligned, with copy buttons), adding
|
||||
> 4 keys (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId`,
|
||||
> `modals.model.actions.copyCivitaiId` / `.civitaiIdCopied`). The same pass removed the
|
||||
> search-options "hash" toggle (`header.search.filters.hash`) because hash/id search is
|
||||
> now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"),
|
||||
> so the "no remaining placeholders" claim holds again.
|
||||
|
||||
> **Status (2026-10, showcase layout option):** the model modal's example images became
|
||||
> switchable between the on-demand gallery and the classic vertical list (issue #1136),
|
||||
> adding 6 keys (`settings.layoutSettings.showcaseLayout`, `.showcaseLayoutHelp`,
|
||||
> `.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.
|
||||
|
||||
> **Status (2026-10, model-modal sidecar location):** the model modal's Location row gained
|
||||
> an inline "open metadata location" icon button (rendered only in centralized sidecar
|
||||
> storage mode), adding 5 keys (`modals.model.actions.openSidecarLocation` and the 4
|
||||
> `modals.model.openSidecarLocation.*` toasts). The renderings mirror each locale's sibling
|
||||
> `openFileLocation` block verbatim with the §5 "metadata" noun swapped in, so no new
|
||||
> terminology was introduced. All 9 locales are translated, so the "no remaining
|
||||
> placeholders" claim holds again.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard rules (do not violate)
|
||||
@@ -391,11 +516,57 @@ The model-root sidebar manages on-disk folders. "Folder" reuses the noun already
|
||||
| tree view / list view | zh-CN 树形视图 / 列表视图 · zh-TW 樹狀檢視 / 清單檢視 · ja ツリー表示 / リスト表示 · ko 트리 보기 / 목록 보기 · fr Vue arborescente / Vue liste · de Baumansicht / Listenansicht · es Vista de árbol / Vista de lista · ru Дерево / Список · he תצוגת עץ / תצוגת רשימה |
|
||||
| sidebar | reuse each locale's `sidebar.hideOnThisPage` noun: zh-CN 侧边栏 · zh-TW 側邊欄 · ja サイドバー · ko 사이드바 · fr barre latérale · de Seitenleiste · es barra lateral · ru боковая панель · he סרגל צד |
|
||||
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and
|
||||
`sidebar.deleteFolderModal.notEmptyMessage` states the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{message}` tokens in
|
||||
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
|
||||
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
|
||||
Deleting a folder **never cascades over model files** — the backend refuses it and the
|
||||
`sidebar.deleteFolderModal.notEmptyMessage*` keys state the rule in every locale, so keep that
|
||||
clause (and its `—`) when the copy is edited. The three variants split by what the modal knows:
|
||||
`notEmptyMessage` (no counts), `notEmptyMessageCount` (`{count}`, the blocking models are all
|
||||
listed) and `notEmptyMessageExcluded` (`{count}` + `{excluded}`, at least one is hidden by the
|
||||
`exclude` flag — the case where the folder legitimately looks empty). `checking` ("Checking the
|
||||
folder contents...", ASCII ellipsis) shows while the backend dry run is pending, `busyTitle`
|
||||
titles the already-pending-staged-delete state, and `notEmptyWithCount` mirrors
|
||||
`deleteFolderResult.notEmpty` with the count for the stale-tree toast.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| excluded from the library | zh-CN 已从模型库中排除 · zh-TW 已從模型庫中排除 · ja ライブラリから除外 · ko 라이브러리에서 제외 · fr exclu de la bibliothèque · de von der Bibliothek ausgeschlossen · es excluido de la biblioteca · ru исключены из библиотеки · he מוחרגים מהספרייה |
|
||||
| un-exclude (verb) | zh-CN 取消排除 · zh-TW 取消排除 · ja 除外を解除 · ko 제외를 해제 · fr annuler l'exclusion · de den Ausschluss aufheben · es anular la exclusión · ru снять исключение · he לבטל את ההחרגה |
|
||||
| "Manage Excluded Models" quoted in prose | zh-CN “管理已排除的模型” · zh-TW 「管理已排除的模型」 · ja 「除外モデルを管理」 · ko '제외된 모델 관리' · fr « Gérer les modèles exclus » · de „Ausgeschlossene Modelle verwalten“ · es «Gestionar modelos excluidos» · ru «Управление исключёнными моделями» · he «ניהול מודלים מוחרגים» |
|
||||
|
||||
A UI label quoted inside prose follows each locale's existing help-text style (zh-CN “ ”,
|
||||
zh-TW/ja 「 」, ko ASCII `' '`, fr/ru/es/he « », de „ “) — see `settings.hideEarlyAccessUpdates.help`
|
||||
/ `settings.civitaiHost.help` as the precedent. `קובצי מודלים` is the Hebrew model-file noun
|
||||
(`notEmptyMessage`); keep it identical in all four Hebrew keys.
|
||||
|
||||
The `{name}` / `{count}` / `{excluded}` / `{message}` tokens in `sidebar.createFolderResult.*`,
|
||||
`sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*` are verbatim §1-R2
|
||||
placeholders. The keys carrying `{count}` are `successWithFiles`, `notEmptyMessageCount`,
|
||||
`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
|
||||
|
||||
@@ -418,6 +589,48 @@ the **noun for arranging files**, matching each locale's existing
|
||||
|
||||
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
|
||||
|
||||
### Sidecar storage feature (centralized `.metadata.json` / preview storage)
|
||||
|
||||
The Library settings tab hosts an optional mode that stores `.metadata.json` sidecars and
|
||||
preview images either **alongside** each model file or in a single **centralized** mirror tree,
|
||||
plus the manual migration that moves existing files between the two. Everything lives in
|
||||
`settings.sections.sidecarStorage` (the section header inside the Library tab),
|
||||
`settings.sidecarStorage.*` and `modals.sidecarMigrationConfirm.*`.
|
||||
|
||||
- **`sidecar` is a technical noun, not a brand**, so each locale either borrows it or uses its
|
||||
own companion-file word — one rendering per file:
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| sidecar (noun) | zh-CN 附属文件 · zh-TW 附屬檔案 · ja サイドカーファイル · ko 사이드카 파일 · fr fichier sidecar · de Sidecar-Datei · es archivo sidecar · ru sidecar-файл · he קובץ לוואי |
|
||||
| centralized storage | zh-CN 集中存储 · zh-TW 集中儲存 · ja 集中保存 · ko 중앙 집중식 저장 · fr stockage centralisé · de zentrale Speicherung · es almacenamiento centralizado · ru централизованное хранилище · he אחסון מרכזי |
|
||||
| alongside model files | zh-CN 与模型文件放在一起 · zh-TW 與模型檔案放在一起 · ja モデルファイルの隣 · ko 모델 파일 옆 · fr à côté des fichiers de modèle · de neben den Modelldateien · es junto a los archivos de modelo · ru рядом с файлами моделей · he לצד קובצי המודלים |
|
||||
| migrate (verb/noun) | zh-CN 迁移 · zh-TW 遷移 · ja 移動 · ko 이동 · fr migrer / migration · de verschieben / Migration · es migrar / migración · ru перенести / перенос · he להעביר / העברה |
|
||||
| mirror (verb) | zh-CN 镜像 · zh-TW 對應 · ja ミラーリング · ko 미러링 · fr refléter · de spiegeln · es reflejar · ru повторять структуру · he לשקף |
|
||||
| preview images | zh-CN 预览图片 · zh-TW 預覽圖片 · ja プレビュー画像 · ko 미리보기 이미지 · fr images d’aperçu · de Vorschaubilder · es imágenes de vista previa · ru изображения превью · he תמונות תצוגה מקדימה |
|
||||
| (effective) storage location | zh-CN (实际)存储位置 · zh-TW (實際)儲存位置 · ja (実際の)保存場所 · ko (실제) 저장 위치 · fr emplacement de stockage (effectif) · de (tatsächlicher) Speicherort · es ubicación de almacenamiento (efectiva) · ru (фактическое) расположение хранилища · he מיקום האחסון (בפועל) |
|
||||
| Open Folder (button) | zh-CN 打开文件夹 · zh-TW 開啟資料夾 · ja フォルダを開く · ko 폴더 열기 · fr Ouvrir le dossier · de Ordner öffnen · es Abrir carpeta · ru Открыть папку · he פתח תיקייה |
|
||||
| installation folder | zh-CN 安装目录 · zh-TW 安裝目錄 · ja インストールフォルダ · ko 설치 폴더 · fr dossier d’installation · de Installationsordner · es carpeta de instalación · ru папка установки · he תיקיית ההתקנה |
|
||||
|
||||
- `ja`/`ko` follow the file's existing storage-relocation verb (ja 移動, ko 이동, from
|
||||
`settings.folderSettings.recipesPathMigrating`) rather than a transliteration of "migration";
|
||||
`ru` uses перенос for the same reason, and `de` keeps the loan noun `Migration` while the verbs
|
||||
use `verschieben`.
|
||||
- **`.metadata.json`**, **`.civitai.info`** and the default-path literal
|
||||
`(<settings dir>/sidecars)` stay byte-identical in every locale — they are file names and a
|
||||
path, not prose (§6 exception). Hebrew drops the wrapping parentheses to avoid bidi mirroring
|
||||
and writes the literal bare.
|
||||
- `migrationDeferred` names a navigation path ("Settings → Library → Sidecar Storage"), so each
|
||||
locale renders it with its **own** settings label and Library tab label
|
||||
(`common.actions.settings` + `settings.nav.library` + the new section label), using the same
|
||||
arrow and quoting style its other nav-path strings already use — zh-CN “设置 → 库 → …”,
|
||||
zh-TW/ja 「設定 > … > …」, ko `설정 → …` bare, fr/de/es bare
|
||||
(`Paramètres` / `Einstellungen` / `Configuración` → …), ru «Настройки → …»,
|
||||
he `הגדרות > …` bare (cf. `other.noPaths.descriptionStandalone`).
|
||||
- The migrate-button label is quoted inside `confirmToCentralized` / `confirmToAlongside` with
|
||||
each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」, ko `' '`, fr/ru/es/he « »,
|
||||
de „ “), matching `settings.sidecarStorage.migrateButton` verbatim so the two never drift.
|
||||
|
||||
### Filename Templates feature
|
||||
|
||||
Per-model-type templates that name downloaded model files; "Apply to Library Now"
|
||||
@@ -455,6 +668,129 @@ short and imperative and do not append a keyboard hint in any locale.
|
||||
|
||||
The grip itself is an icon and is never translated.
|
||||
|
||||
### Download routing feature (unknown base model routing)
|
||||
|
||||
The `settings.unknownBaseModelRouting.*` keys (Settings → Library → Folder Settings) decide
|
||||
which library a checkpoint download lands in when CivitAI reports a baseModel that is in
|
||||
neither the known-checkpoint nor the known-diffusion-model list. The option labels reuse
|
||||
each locale's `checkpoints.modelTypes.diffusion_model` / `.checkpoint` renderings
|
||||
(model-type names, R3 — ja/ko keep the Latin loanword), pluralized only where the locale
|
||||
pluralizes (de Diffusionsmodelle, es Modelos de difusión, fr Modèles de diffusion,
|
||||
ru Диффузионные модели, he מודלי דיפוזיה; CJK stays singular, "Checkpoint(s)" follows
|
||||
`header.navigation.checkpoints`).
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| routing (noun, of a download into a library) | zh-CN 路由 · zh-TW 路由 · ja 振り分け · ko 라우팅 · fr routage · de Routing · es enrutamiento · ru маршрутизация · he ניתוב |
|
||||
| unknown base model | zh-CN 未知基础模型 · zh-TW 未知基礎模型 · ja 不明なベースモデル · ko 알 수 없는 베이스 모델 · fr modèle de base inconnu · de unbekanntes Basismodell · es modelo base desconocido · ru неизвестная базовая модель · he מודל בסיס לא מוכר |
|
||||
| destination type (download-modal toggle label) | zh-CN 目标类型 · zh-TW 目標類型 · ja 保存先タイプ · ko 대상 유형 · fr type de destination · de Zieltyp · es tipo de destino · ru тип назначения · he סוג יעד |
|
||||
|
||||
The baseModel family names in the help text (`SD 1.x/2.x/3.x, SDXL, Pony, Illustrious,
|
||||
NoobAI`) are CivitAI baseModel values and stay verbatim in every locale.
|
||||
|
||||
The routing-override toggle (`modals.download.routingOverride.*`) sits on the checkpoints
|
||||
page of the download modal; its two button labels come from `checkpoints.modelTypes.*`
|
||||
directly (model-type names, R3). The tooltip quotes the `modals.download.useDefaultPath`
|
||||
label verbatim with each locale's UI-label quoting style (zh-CN “ ”, zh-TW/ja 「 」,
|
||||
ko `' '`, fr « … », de „ … “, es/ru/he «…»).
|
||||
|
||||
### OpenModelDB feature
|
||||
|
||||
**OpenModelDB** is a brand name and stays Latin in every locale (R3, same as CivitAI /
|
||||
CivArchive); `openmodeldb.info` is a URL and stays verbatim. **Upscaler** follows the
|
||||
Other Models rule (model-type name, Latin everywhere). The label/help mirror each
|
||||
locale's existing `settings.metadataArchive.enableCivarchiveApi(Help)` phrasing, and
|
||||
"metadata" uses the §5 rendering per locale.
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| catalogue (the OpenModelDB catalogue) | zh-CN 目录 · zh-TW 目錄 · ja カタログ · ko 카탈로그 · fr catalogue · de Katalog · es catálogo · ru каталог · he קטלוג |
|
||||
|
||||
### Civitai ids feature (model/version id in the model modal)
|
||||
|
||||
The model modal's hash footnote shows the Civitai **model id** and **version id** with
|
||||
copy buttons (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId` labels,
|
||||
`modals.model.actions.copyCivitaiId` tooltip, `.civitaiIdCopied` toast). **"ID" stays
|
||||
Latin in every locale** (same precedent as `recipes.*.copyId`), and `Civitai` is the
|
||||
brand (R3) — it is never translated or transliterated; the casing mirrors `en.json`
|
||||
verbatim (R9). The copy/copied strings reuse each locale's existing clipboard patterns
|
||||
(`modals.model.actions.copyHash` / `openFileLocation.copied`).
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| Model ID (label) | zh-CN 模型 ID · zh-TW 模型 ID · ja モデル ID · ko 모델 ID · fr ID du modèle · de Modell-ID · es ID del modelo · ru ID модели · he מזהה מודל |
|
||||
| Version ID (label) | zh-CN 版本 ID · zh-TW 版本 ID · ja バージョン ID · ko 버전 ID · fr ID de version · de Versions-ID · es ID de versión · ru ID версии · he מזהה גרסה |
|
||||
|
||||
Hebrew uses its established מזהה ("identifier") noun instead of Latin `ID` in these
|
||||
labels, matching `recipes.*.copyId` (העתק מזהה מתכון).
|
||||
|
||||
### Showcase layout feature (gallery / vertical list toggle)
|
||||
|
||||
The model modal's example images can switch between a one-at-a-time **gallery** and the
|
||||
classic **vertical list**, via a Settings select (`settings.layoutSettings.showcaseLayout*`,
|
||||
mirroring the `recipesLayout*` select shape) and an in-modal segmented toggle whose two
|
||||
icon buttons are labelled by `modals.model.showcase.layoutGallery` / `.layoutList`
|
||||
(kept short — they are icon-button tooltips).
|
||||
|
||||
"Showcase Layout" is rendered as the **example-images layout** in most locales (the
|
||||
section's user-facing content), reusing each locale's fixed "example images" noun
|
||||
(`modelCardFooterActionOptions.exampleImages`); ja keeps its established ショーケース
|
||||
loanword instead. The `layoutList` tooltip reuses the fixed "list view" noun from the
|
||||
folder-sidebar row (above), so it stays byte-consistent with `sidebar.listView` where
|
||||
that form fits a tooltip (ru shortens both toggle labels to bare «Галерея» / «Список»).
|
||||
|
||||
| Term | Rendering |
|
||||
|---|---|
|
||||
| showcase layout (settings label) | zh-CN 示例图片布局 · zh-TW 範例圖片版面 · ja ショーケースのレイアウト · ko 예시 이미지 레이아웃 · fr Disposition des images d'exemple · de Beispielbilder-Layout · es Diseño de imágenes de ejemplo · ru Макет примеров изображений · he פריסת תמונות דוגמה |
|
||||
| example images | zh-CN 示例图片 · zh-TW 範例圖片 · ja 例画像 · ko 예시 이미지 · fr images d'exemple · de Beispielbilder · es imágenes de ejemplo · ru примеры изображений · he תמונות דוגמה |
|
||||
| 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
|
||||
|
||||
@@ -24,11 +24,16 @@ this change and refresh the page. This Python node needs no Vue widget build.
|
||||
model/LoRA names, local resolution status and warnings. The original `report`
|
||||
output remains notes followed by formatted JSON; it is not a pure JSON string.
|
||||
|
||||
`model_name`, `sampler_name`, and `scheduler` use COMBO outputs for converted
|
||||
dropdown inputs in current ComfyUI. `model_name` contains the matching local
|
||||
checkpoint or diffusion-model filename. The report identifies the resolved type;
|
||||
connect it to the appropriate loader. Lookup searches both categories regardless
|
||||
of how the original metadata labels the model.
|
||||
`model_name`, `sampler_name`, and `scheduler` are declared as untyped (`*`)
|
||||
outputs so they can feed the dropdown widget inputs on both
|
||||
**Load Checkpoint**/**KSampler** and the LoRA Manager loaders. Typing them
|
||||
`COMBO` does not work: ComfyUI only accepts a `COMBO` output into a node that
|
||||
declares its dropdown as `COMBO`/`IO.Combo`, while classic dropdowns expose a
|
||||
plain option list, and the server rejects the link with "Return type mismatch
|
||||
between linked nodes". `model_name` contains the matching local checkpoint or
|
||||
diffusion-model filename. The report identifies the resolved type; connect it to
|
||||
the appropriate loader. Lookup searches both categories regardless of how the
|
||||
original metadata labels the model.
|
||||
|
||||
For a diffusion-model workflow, connect `model_name` to **Unet Loader
|
||||
(LoraManager)** and select the correct text encoder(s), VAE, latent node and
|
||||
|
||||
@@ -11,6 +11,33 @@ This document defines the complete schema for `.metadata.json` files used by Lor
|
||||
|
||||
---
|
||||
|
||||
## Storage Location (Alongside vs Centralized)
|
||||
|
||||
By default, `.metadata.json` sidecars and preview images live **alongside** their model files. An optional centralized mode stores them under a single root directory instead. Two settings control this (Settings → Library → Sidecar Storage):
|
||||
|
||||
| Setting | Values | Default |
|
||||
|---------|--------|---------|
|
||||
| `sidecar_storage_mode` | `"alongside"` \| `"centralized"` | `"alongside"` |
|
||||
| `sidecar_storage_path` | Absolute path string; empty = `<settings dir>/sidecars` | `""` |
|
||||
|
||||
In centralized mode, sidecars and previews mirror each model root's directory structure:
|
||||
|
||||
```
|
||||
<sidecar_root>/<root_component>/<rel_dir>/<name>.metadata.json
|
||||
```
|
||||
|
||||
- `<rel_dir>` is the model's directory relative to the model root containing the file; the longest matching root wins, so nested roots mirror under the most specific root.
|
||||
- `<root_component>` identifies the model root and **survives the root being moved or renamed**. It starts as the deterministic `<sanitized basename>-<path digest>` — so mirrors created by older builds, and mirrors left behind by a relocated sidecar root, still resolve — and is then pinned in `<sidecar_root>/.lm-sidecar-roots.json` alongside the root's last known path and a few sample subdirectories. Two roots sharing a basename (e.g. `/mnt/a/loras` and `/mnt/b/loras`) always get distinct components and never collide. Each path component is sanitized to filesystem-safe characters.
|
||||
- **Moving or renaming a model root does not strand its sidecars.** On the next run the mirror identity is re-anchored to the root's new path (matched by basename and recorded sample directories), so favorites, notes, tags and usage tips keep resolving. An existing hash-named mirror from an older build is adopted as-is on first use.
|
||||
- If an identity cannot be re-anchored unambiguously (e.g. two same-named candidate roots), nothing is guessed: the mirror stays on disk untouched and surfaces as an orphan in **Doctor → Centralized Sidecars** (and in the log). Restoring the original root path re-links it automatically.
|
||||
- `.civitai.info` files always stay next to the model file, in both modes.
|
||||
- Changing the mode does **not** move existing files automatically — run the migration (`POST /api/lm/sidecars/migrate` with `{"direction": "to_centralized" | "to_alongside"}`, or the "Migrate Sidecars Now" button in settings). The migration covers excluded (hidden) models too, so un-excluding one later never strands its sidecar in the old layout. The result payload includes a `sidecar_root` field with the resolved centralized root, and the settings UI shows the outcome counters plus an "Open Folder" shortcut.
|
||||
- Changing `sidecar_storage_path` while centralized likewise needs a root relocation: `{"direction": "relocate_root", "old_root": "<previous path>"}` moves the whole mirror tree to the new root (the settings UI offers this automatically). The identity map travels with the tree, and its entries win over any map the destination acquired beforehand — so a mirror that was re-anchored earlier keeps its name even if something resolved against the new path before the relocation ran.
|
||||
- The settings UI always shows the resolved effective storage root (via the `sidecar_storage_root*` fields in `GET /api/lm/settings`), with `POST /api/lm/sidecars/open-location` opening it in the file manager. When the resolved root lies inside the plugin installation folder (portable settings mode), the UI warns: reinstalling or clean-updating the plugin would delete the sidecars, so an explicit path outside the installation folder is recommended. The repo `.gitignore` excludes the portable-mode default (`/sidecars/`).
|
||||
- All sidecar/preview path derivation goes through the helpers in `py/utils/sidecar_paths.py`; never construct paths inline. In the default `alongside` mode these helpers do no extra I/O at all — the identity map is only loaded and reconciled when centralized storage is actually in use.
|
||||
|
||||
---
|
||||
|
||||
## Base Fields (All Model Types)
|
||||
|
||||
These fields are present in all model metadata files.
|
||||
@@ -272,9 +299,29 @@ The `metadata_source` field indicates which provider last updated the metadata:
|
||||
|-------|--------|
|
||||
| `"civitai_api"` | Civitai API |
|
||||
| `"civarchive"` | CivArchive API |
|
||||
| `"openmodeldb"` | OpenModelDB catalogue (upscaler models only; hash-matched) |
|
||||
| `"archive_db"` | Metadata Archive Database |
|
||||
| `null` | No external source (user-defined only) |
|
||||
|
||||
When `metadata_source` is `"openmodeldb"`, the `civitai` payload is a
|
||||
CivitAI-shaped version dict synthesized from the OpenModelDB catalogue entry
|
||||
(no numeric `id`/`modelId`), and the OpenModelDB-native details live in its
|
||||
`openmodeldb` block (`id`, `url`, `authors`, `architecture`,
|
||||
`architectureName`, `scale`, `license`, `date`).
|
||||
|
||||
In that payload, `images[].url` is always a displayable asset: paired
|
||||
comparisons use the site-hosted thumbnail because the `LR`/`SR` originals
|
||||
are ephemeral imgdiff viewer sessions that 404 outside them (the original
|
||||
viewer link is kept as `images[].meta.comparisonUrl` for reference), and the
|
||||
model-level thumbnail leads the list since the card preview derives from
|
||||
`images[0]`. `files[].name` is derived from any URL path segment with a
|
||||
model extension (mediafire-style mirrors bury it mid-path) or synthesized as
|
||||
`{model_id}.{type}` for folder links.
|
||||
|
||||
Models downloaded from OpenModelDB additionally carry `source_platform:
|
||||
"openmodeldb"` and `source_url` (the model page URL); their download-time
|
||||
hydration is recorded as `metadata_source: "source:openmodeldb"`.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Update Behavior
|
||||
|
||||
@@ -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%,两段之间不会回退。
|
||||
|
||||
+169
-12
@@ -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",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "Modellname",
|
||||
"tags": "Tags",
|
||||
"creator": "Ersteller",
|
||||
"hash": "Hash",
|
||||
"title": "Rezept-Titel",
|
||||
"loraName": "LoRA-Dateiname",
|
||||
"loraModel": "LoRA-Modellname",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "LoRA-Verfügbarkeit",
|
||||
"availabilityReady": "Einsatzbereit",
|
||||
"availabilityMissing": "Mit fehlenden LoRAs",
|
||||
"availabilityDeleted": "Mit gelöschten LoRAs"
|
||||
"availabilityDeleted": "Mit gelöschten LoRAs",
|
||||
"workflow": "Workflow",
|
||||
"hasWorkflow": "Mit Workflow"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Theme wechseln",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
|
||||
},
|
||||
"aria2HelpLink": "Erfahren Sie, wie Sie das aria2-Download-Backend einrichten",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Routing unbekannter Basismodelle",
|
||||
"help": "Legt fest, wohin Checkpoint-Downloads gehen, wenn CivitAI ein Basismodell meldet, das weder ein bekanntes Checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) noch ein bekanntes Diffusionsmodell ist. Neue Diffusionsarchitekturen erscheinen häufig, daher ist das Routing zu Diffusionsmodellen meist korrekt.",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusionsmodelle",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI-Host-Einstellung verfügbar",
|
||||
"content": "CivitAI verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
"versionScope": "Update-Markierungen",
|
||||
"priceTracking": "Buzz-Downloadpreise",
|
||||
"exampleImages": "Beispielbilder",
|
||||
"autoOrganize": "Auto-Organisierung",
|
||||
"metadata": "Metadaten",
|
||||
"sidecarStorage": "Sidecar-Speicherung",
|
||||
"proxySettings": "Proxy-Einstellungen"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "Bei Hover anzeigen"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||
"showcaseLayout": "Beispielbilder-Layout",
|
||||
"showcaseLayoutHelp": "Wählen Sie, wie Beispielbilder im Modell-Dialog angezeigt werden: eine Galerie mit jeweils einem Bild oder die klassische vertikale Liste.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Galerie",
|
||||
"vertical": "Vertikale Liste"
|
||||
},
|
||||
"showVersionOnCard": "Version auf Karte anzeigen",
|
||||
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
|
||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||
@@ -745,6 +767,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."
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "Download erfolgreich abgeschlossen",
|
||||
"enableCivarchiveApi": "CivArchive API als Metadaten-Anbieter aktivieren",
|
||||
"enableCivarchiveApiHelp": "Wenn aktiviert, wird die CivArchive API als alternative Quelle für Modell-Metadaten verwendet (z. B. für von CivitAI gelöschte Modelle). Deaktivieren, um die Ratenbegrenzungen von CivArchive vollständig zu vermeiden.",
|
||||
"enableOpenmodeldbApi": "OpenModelDB als Metadaten-Anbieter aktivieren",
|
||||
"enableOpenmodeldbApiHelp": "Wenn aktiviert, werden Upscaler-Metadaten zusätzlich im OpenModelDB-Katalog (openmodeldb.info) nachgeschlagen, wenn CivitAI keinen Eintrag hat. Der Katalog wird lokal zwischengespeichert und täglich aktualisiert.",
|
||||
"providerOrder": "Reihenfolge der Metadaten-Anbieter",
|
||||
"providerOrderHelp": "Die CivitAI API wird immer zuerst versucht. Wählen Sie die Reihenfolge der übrigen Anbieter bei der Metadatensuche.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Sidecar-Speichermodus",
|
||||
"modeHelp": "Wählen Sie, wo .metadata.json-Sidecar-Dateien und Vorschaubilder gespeichert werden: neben jeder Modelldatei oder in einem einzelnen zentralen Verzeichnis, das Ihre Bibliotheksstruktur spiegelt. .civitai.info-Dateien bleiben immer neben der Modelldatei.",
|
||||
"modeOptions": {
|
||||
"alongside": "Neben den Modelldateien (Standard)",
|
||||
"centralized": "Zentrale Speicherung"
|
||||
},
|
||||
"path": "Pfad der zentralen Speicherung",
|
||||
"pathHelp": "Stammverzeichnis für die zentrale Sidecar-Speicherung. Leer lassen, um den Standardspeicherort (<settings dir>/sidecars) zu verwenden.",
|
||||
"pathPlaceholder": "Leer = <settings dir>/sidecars",
|
||||
"management": "Sidecar-Migration",
|
||||
"managementHelp": "Verschieben Sie vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder zwischen der Ablage neben den Modelldateien und der zentralen Speicherung, passend zum aktuell gewählten Modus. Ein Moduswechsel verschiebt vorhandene Dateien nicht automatisch.",
|
||||
"migrateButton": "Sidecar-Dateien jetzt verschieben",
|
||||
"migratingButton": "Wird verschoben...",
|
||||
"migrating": "Sidecar-Dateien werden verschoben...",
|
||||
"migrateFailed": "Sidecar-Migration fehlgeschlagen: {message}",
|
||||
"migrationDeferred": "Vorhandene Sidecar-Dateien wurden nicht verschoben. Sie können sie später unter Einstellungen → Bibliothek → Sidecar-Speicherung verschieben.",
|
||||
"confirmToCentralized": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt in das zentrale Speicherverzeichnis verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
|
||||
"confirmToAlongside": "Der Speichermodus wurde geändert, aber vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder werden nicht automatisch verschoben. Jetzt wieder neben ihre Modelldateien verschieben? Sie können das auch später über die Schaltfläche „Sidecar-Dateien jetzt verschieben“ tun.",
|
||||
"confirmToCentralizedManual": "Vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder jetzt in das zentrale Speicherverzeichnis verschieben?",
|
||||
"confirmToAlongsideManual": "Vorhandene .metadata.json-Sidecar-Dateien und Vorschaubilder jetzt wieder neben ihre Modelldateien verschieben?",
|
||||
"confirmRelocateRoot": "Das zentrale Speicherverzeichnis wurde geändert, aber vorhandene Sidecar-Dateien und Vorschaubilder liegen noch im vorherigen Verzeichnis. Jetzt in das neue Verzeichnis verschieben?",
|
||||
"effectivePathLabel": "Tatsächlicher Speicherort:",
|
||||
"openFolderButton": "Ordner öffnen",
|
||||
"repoWarning": "Der tatsächliche Speicherort liegt innerhalb des LoRA-Manager-Installationsordners. Eine Neuinstallation des Plugins oder eine saubere Aktualisierung kann ihn löschen — legen Sie einen expliziten Speicherpfad außerhalb des Installationsordners fest.",
|
||||
"openLocationSuccess": "Sidecar-Speicherordner geöffnet",
|
||||
"openLocationCopied": "Sidecar-Speicherpfad in die Zwischenablage kopiert: {path}",
|
||||
"openLocationClipboardFallback": "Kopieren Sie den Sidecar-Speicherpfad manuell: {path}",
|
||||
"openLocationFailed": "Sidecar-Speicherordner konnte nicht geöffnet werden"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "App-Proxy aktivieren",
|
||||
"enableProxyHelp": "Aktivieren Sie benutzerdefinierte Proxy-Einstellungen für diese Anwendung. Überschreibt die System-Proxy-Einstellungen.",
|
||||
@@ -861,7 +922,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",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "Workflow an ComfyUI senden",
|
||||
"sent": "Workflow an ComfyUI gesendet",
|
||||
"sendFailed": "Fehler beim Senden des Workflows an ComfyUI",
|
||||
"noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden"
|
||||
"noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden",
|
||||
"hasWorkflow": "Enthält einen ComfyUI-Workflow"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Einsatzbereit",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "Dieser Ordner enthält keine Modelle. Alle anderen darin enthaltenen Dateien werden ebenfalls gelöscht.",
|
||||
"notEmptyTitle": "Ordner ist nicht leer",
|
||||
"notEmptyMessage": "Dieser Ordner enthält noch Modelle. Löschen oder verschieben Sie diese zuerst — beim Löschen eines Ordners werden Modelldateien niemals mitgelöscht.",
|
||||
"confirm": "Ordner löschen"
|
||||
"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",
|
||||
"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",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "Ordner wiederhergestellt",
|
||||
"failed": "Ordner konnte nicht gelöscht werden: {message}",
|
||||
"notEmpty": "Dieser Ordner enthält noch Modelle. Aktualisieren Sie die Seitenleiste und versuchen Sie es erneut.",
|
||||
"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": {
|
||||
@@ -1369,7 +1451,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.",
|
||||
@@ -1389,6 +1490,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": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "Wählen Sie ein Stammverzeichnis",
|
||||
"selectModelRoot": "Modell-Stammverzeichnis auswählen:",
|
||||
"selectTypeRoot": "{type}-Stammverzeichnis auswählen:",
|
||||
"routingOverride": {
|
||||
"label": "Zieltyp:",
|
||||
"tooltip": "Automatisch aus den Modell-Metadaten erkannt. Wechseln Sie, wenn die Erkennung falsch war; ein Wechsel deaktiviert „Standardpfad verwenden“ für diesen Download."
|
||||
},
|
||||
"targetFolderPath": "Zielordnerpfad:",
|
||||
"browseFolders": "Ordner durchsuchen:",
|
||||
"createNewFolder": "Neuen Ordner erstellen",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
|
||||
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Sidecar-Dateien in die zentrale Speicherung verschieben?",
|
||||
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
|
||||
"confirmButton": "Jetzt verschieben",
|
||||
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
|
||||
"destination": "Ziel: {path}",
|
||||
"defaultLocationHint": "Es wird der Standard-Speicherort verwendet. Um einen eigenen Ordner zu nutzen, brechen Sie ab, setzen Sie oben den Speicherpfad und klicken Sie dann auf „Sidecar-Dateien jetzt verschieben“."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Sidecar-Migrationsübersicht",
|
||||
"completedSuccessfully": "Migration erfolgreich abgeschlossen",
|
||||
"completedWithErrors": "Abgeschlossen mit {count} Fehler(n)",
|
||||
"statMoved": "Verschobene Dateien",
|
||||
"statModels": "Modelle",
|
||||
"statSkipped": "Übersprungen",
|
||||
"statConflicts": "Gelöste Konflikte",
|
||||
"statErrors": "Fehler",
|
||||
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||
"columnModel": "Modell",
|
||||
"columnError": "Fehler",
|
||||
"successMessage": "{moved} Dateien für {models} Modelle verschoben",
|
||||
"location": "Speicherort: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "Auf Hugging Face ansehen",
|
||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"openSidecarLocation": "Metadaten-Speicherort öffnen",
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
"sendToWorkflowText": "An ComfyUI senden",
|
||||
"copyHash": "Hash kopieren",
|
||||
"copyCivitaiId": "Civitai-ID kopieren",
|
||||
"civitaiIdCopied": "Civitai-ID in die Zwischenablage kopiert",
|
||||
"deleteModelWithShortcut": "Modell löschen (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Pfad: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Metadaten-Speicherort erfolgreich geöffnet",
|
||||
"failed": "Fehler beim Öffnen des Metadaten-Speicherorts",
|
||||
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
|
||||
"clipboardFallback": "Pfad: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Kann nicht an ComfyUI senden: Kein Dateipfad verfügbar"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "Basismodell",
|
||||
"size": "Größe",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "Modell-ID",
|
||||
"civitaiVersionId": "Versions-ID",
|
||||
"unknown": "Unbekannt",
|
||||
"usageTips": "Nutzungstipps",
|
||||
"additionalNotes": "Zusätzliche Notizen",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "Nicht jugendfreie Inhalte",
|
||||
"nsfwR": "Inhalte ab 18 (R)",
|
||||
"nsfwX": "Inhalte mit X-Einstufung",
|
||||
"nsfwXxx": "Inhalte mit XXX-Einstufung"
|
||||
"nsfwXxx": "Inhalte mit XXX-Einstufung",
|
||||
"layoutGallery": "Galerieansicht",
|
||||
"layoutList": "Listenansicht"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Modellversionen",
|
||||
@@ -1935,10 +2080,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",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "Erfolgreich",
|
||||
"statFailed": "Fehlgeschlagen",
|
||||
"statSkipped": "Übersprungen",
|
||||
"statOffline": "Laufwerk offline",
|
||||
"statTotal": "Gesamt geprüft",
|
||||
"statDuration": "Dauer",
|
||||
"successMessage": "Alle {count} {type}s erfolgreich aktualisiert!",
|
||||
@@ -2677,7 +2828,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.",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Konflikte durch doppelte Dateinamen"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Zentralisierte Sidecar-Dateien"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI-Version"
|
||||
}
|
||||
|
||||
+171
-14
@@ -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",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "Model Name",
|
||||
"tags": "Tags",
|
||||
"creator": "Creator",
|
||||
"hash": "Hash",
|
||||
"title": "Recipe Title",
|
||||
"loraName": "LoRA Filename",
|
||||
"loraModel": "LoRA Model Name",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "Lora Availability",
|
||||
"availabilityReady": "Ready to use",
|
||||
"availabilityMissing": "Has missing",
|
||||
"availabilityDeleted": "Has deleted"
|
||||
"availabilityDeleted": "Has deleted",
|
||||
"workflow": "Workflow",
|
||||
"hasWorkflow": "Has Workflow"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "Leave empty to use aria2c from PATH"
|
||||
},
|
||||
"aria2HelpLink": "Learn how to set up the aria2 download backend",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Unknown base model routing",
|
||||
"help": "Decides where checkpoint downloads go when CivitAI reports a base model that is neither a known checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) nor a known diffusion model. New diffusion architectures appear frequently, so routing them to diffusion models is usually correct.",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusion models",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI host preference available",
|
||||
"content": "CivitAI now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "Download Path Templates",
|
||||
"priorityTags": "Priority Tags",
|
||||
"versionScope": "Version Scope",
|
||||
"priceTracking": "Buzz Download Prices",
|
||||
"exampleImages": "Example Images",
|
||||
"autoOrganize": "Auto-organize",
|
||||
"metadata": "Metadata",
|
||||
"sidecarStorage": "Sidecar Storage",
|
||||
"proxySettings": "Proxy Settings"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "Reveal on Hover"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||
"showcaseLayout": "Showcase Layout",
|
||||
"showcaseLayoutHelp": "Choose how example images are displayed in the model modal: a one-at-a-time gallery or the classic vertical list.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Gallery",
|
||||
"vertical": "Vertical List"
|
||||
},
|
||||
"showVersionOnCard": "Show Version on Card",
|
||||
"showVersionOnCardHelp": "Show or hide the version name on model cards",
|
||||
"modelCardFooterAction": "Model Card Button Action",
|
||||
@@ -535,8 +557,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",
|
||||
@@ -745,6 +767,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."
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "Download completed successfully",
|
||||
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
|
||||
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
|
||||
"enableOpenmodeldbApi": "Enable OpenModelDB as metadata provider",
|
||||
"enableOpenmodeldbApiHelp": "When on, upscaler metadata is also looked up in the OpenModelDB catalogue (openmodeldb.info) when CivitAI has no record. The catalogue is cached locally and refreshed daily.",
|
||||
"providerOrder": "Metadata provider fallback order",
|
||||
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Sidecar Storage Mode",
|
||||
"modeHelp": "Choose where .metadata.json sidecars and preview images are stored: next to each model file, or in a single centralized directory that mirrors your library structure. .civitai.info files always stay next to the model file.",
|
||||
"modeOptions": {
|
||||
"alongside": "Alongside model files (default)",
|
||||
"centralized": "Centralized storage"
|
||||
},
|
||||
"path": "Centralized Storage Path",
|
||||
"pathHelp": "Root directory for centralized sidecar storage. Leave empty to use the default location (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Empty = <settings dir>/sidecars",
|
||||
"management": "Sidecar Migration",
|
||||
"managementHelp": "Move existing .metadata.json sidecars and preview images between alongside and centralized storage, matching the currently selected mode. Changing the mode does not move existing files automatically.",
|
||||
"migrateButton": "Migrate Sidecars Now",
|
||||
"migratingButton": "Migrating...",
|
||||
"migrating": "Migrating sidecars...",
|
||||
"migrateFailed": "Sidecar migration failed: {message}",
|
||||
"migrationDeferred": "Existing sidecars were not moved. You can migrate them later from Settings → Library → Sidecar Storage.",
|
||||
"confirmToCentralized": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the \"Migrate Sidecars Now\" button.",
|
||||
"confirmToAlongside": "The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the \"Migrate Sidecars Now\" button.",
|
||||
"confirmToCentralizedManual": "Move existing .metadata.json sidecars and preview images into the centralized storage directory now?",
|
||||
"confirmToAlongsideManual": "Move existing .metadata.json sidecars and preview images back next to their model files now?",
|
||||
"confirmRelocateRoot": "The centralized storage directory changed, but existing sidecars and preview images are still in the previous directory. Move them to the new directory now?",
|
||||
"effectivePathLabel": "Effective storage location:",
|
||||
"openFolderButton": "Open Folder",
|
||||
"repoWarning": "The effective storage location is inside the LoRA Manager installation folder. Reinstalling the plugin or a clean update can delete it — set an explicit storage path outside the installation folder.",
|
||||
"openLocationSuccess": "Opened sidecar storage folder",
|
||||
"openLocationCopied": "Sidecar storage path copied to clipboard: {path}",
|
||||
"openLocationClipboardFallback": "Copy the sidecar storage path manually: {path}",
|
||||
"openLocationFailed": "Failed to open the sidecar storage folder"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Enable App-level Proxy",
|
||||
"enableProxyHelp": "Enable custom proxy settings for this application, overriding system proxy settings",
|
||||
@@ -861,7 +922,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",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "Send Workflow to ComfyUI",
|
||||
"sent": "Workflow sent to ComfyUI",
|
||||
"sendFailed": "Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "No embedded workflow found in this recipe"
|
||||
"noWorkflow": "No embedded workflow found in this recipe",
|
||||
"hasWorkflow": "Contains a ComfyUI workflow"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Ready to use",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "This folder contains no models. Any other files it holds will be deleted too.",
|
||||
"notEmptyTitle": "Folder is not empty",
|
||||
"notEmptyMessage": "This folder still contains models. Delete or move them first — deleting a folder never cascades over model files.",
|
||||
"confirm": "Delete folder"
|
||||
"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",
|
||||
"confirmMulti": "Delete {count} folders",
|
||||
"deleting": "Deleting...",
|
||||
"keptNote": "Unchecked copies keep this folder in the sidebar."
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Folder \"{name}\" deleted",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "Folder restored",
|
||||
"failed": "Failed to delete folder: {message}",
|
||||
"notEmpty": "This folder still contains models. Refresh the sidebar and try again.",
|
||||
"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": {
|
||||
@@ -1369,7 +1451,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.",
|
||||
@@ -1389,6 +1490,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": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "Select a root directory",
|
||||
"selectModelRoot": "Select Model Root:",
|
||||
"selectTypeRoot": "Select {type} Root:",
|
||||
"routingOverride": {
|
||||
"label": "Destination type:",
|
||||
"tooltip": "Auto-detected from the model metadata. Switch if it was misdetected; switching turns off \"Use Default Path\" for this download."
|
||||
},
|
||||
"targetFolderPath": "Target Folder Path:",
|
||||
"browseFolders": "Browse Folders:",
|
||||
"createNewFolder": "Create new folder",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "Restore original filenames?",
|
||||
"revertButton": "Restore Original Filenames"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Move sidecars to centralized storage?",
|
||||
"titleToAlongside": "Move sidecars back next to model files?",
|
||||
"confirmButton": "Migrate Now",
|
||||
"titleRelocateRoot": "Move sidecars to the new storage directory?",
|
||||
"destination": "Destination: {path}",
|
||||
"defaultLocationHint": "This uses the default location. To use a custom folder, cancel, set the storage path above, then click \"Migrate Sidecars Now\"."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Sidecar Migration Summary",
|
||||
"completedSuccessfully": "Migration completed successfully",
|
||||
"completedWithErrors": "Completed with {count} error(s)",
|
||||
"statMoved": "Moved Files",
|
||||
"statModels": "Models",
|
||||
"statSkipped": "Skipped",
|
||||
"statConflicts": "Conflicts Resolved",
|
||||
"statErrors": "Errors",
|
||||
"failedItems": "Failed Items ({count})",
|
||||
"columnModel": "Model",
|
||||
"columnError": "Error",
|
||||
"successMessage": "Moved {moved} files for {models} models",
|
||||
"location": "Storage location: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "View on Hugging Face",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location",
|
||||
"openSidecarLocation": "Open metadata location",
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
"sendToWorkflowText": "Send to ComfyUI",
|
||||
"copyHash": "Copy hash",
|
||||
"copyCivitaiId": "Copy Civitai ID",
|
||||
"civitaiIdCopied": "Civitai ID copied to clipboard",
|
||||
"deleteModelWithShortcut": "Delete model (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "Path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Path: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Metadata location opened successfully",
|
||||
"failed": "Failed to open metadata location",
|
||||
"copied": "Path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Path: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Unable to send to ComfyUI: No file path available"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "Base Model",
|
||||
"size": "Size",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "Model ID",
|
||||
"civitaiVersionId": "Version ID",
|
||||
"unknown": "Unknown",
|
||||
"usageTips": "Usage Tips",
|
||||
"additionalNotes": "Additional Notes",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "Mature Content",
|
||||
"nsfwR": "R-rated Content",
|
||||
"nsfwX": "X-rated Content",
|
||||
"nsfwXxx": "XXX-rated Content"
|
||||
"nsfwXxx": "XXX-rated Content",
|
||||
"layoutGallery": "Gallery view",
|
||||
"layoutList": "List view"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
@@ -1935,10 +2080,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",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "Success",
|
||||
"statFailed": "Failed",
|
||||
"statSkipped": "Skipped",
|
||||
"statOffline": "Drive Offline",
|
||||
"statTotal": "Total Scanned",
|
||||
"statDuration": "Duration",
|
||||
"successMessage": "All {count} {type}s updated successfully!",
|
||||
@@ -2677,7 +2828,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.",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Duplicate Filename Conflicts"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Centralized Sidecars"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI Version"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "Nombre del modelo",
|
||||
"tags": "Etiquetas",
|
||||
"creator": "Creador",
|
||||
"hash": "Hash",
|
||||
"title": "Título de la receta",
|
||||
"loraName": "Nombre de archivo LoRA",
|
||||
"loraModel": "Nombre del modelo LoRA",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "Disponibilidad de LoRAs",
|
||||
"availabilityReady": "Listos para usar",
|
||||
"availabilityMissing": "Con LoRAs faltantes",
|
||||
"availabilityDeleted": "Con LoRAs eliminados"
|
||||
"availabilityDeleted": "Con LoRAs eliminados",
|
||||
"workflow": "Workflow",
|
||||
"hasWorkflow": "Con workflow"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambiar tema",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
|
||||
},
|
||||
"aria2HelpLink": "Aprende a configurar el backend de descarga aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Enrutamiento de modelos base desconocidos",
|
||||
"help": "Decide a dónde van las descargas de Checkpoint cuando CivitAI informa de un modelo base que no es ni un Checkpoint conocido (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ni un modelo de difusión conocido. Las nuevas arquitecturas de difusión aparecen con frecuencia, por lo que enrutarlas a modelos de difusión suele ser correcto.",
|
||||
"options": {
|
||||
"diffusionModel": "Modelos de difusión",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Preferencia de host de CivitAI disponible",
|
||||
"content": "CivitAI ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
|
||||
@@ -380,9 +394,11 @@
|
||||
"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",
|
||||
"sidecarStorage": "Almacenamiento de archivos sidecar",
|
||||
"proxySettings": "Configuración de proxy"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "Mostrar al pasar el ratón"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||
"showcaseLayout": "Diseño de imágenes de ejemplo",
|
||||
"showcaseLayoutHelp": "Elige cómo se muestran las imágenes de ejemplo en el modal del modelo: una galería que las muestra de una en una o la clásica lista vertical.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Galería",
|
||||
"vertical": "Lista vertical"
|
||||
},
|
||||
"showVersionOnCard": "Mostrar versión en la tarjeta",
|
||||
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
|
||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||
@@ -745,6 +767,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."
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "Descarga completada exitosamente",
|
||||
"enableCivarchiveApi": "Habilitar CivArchive API como proveedor de metadatos",
|
||||
"enableCivarchiveApiHelp": "Al activarlo, la API de CivArchive se usa como fuente alternativa de metadatos de modelos (p. ej. para modelos eliminados de CivitAI). Desactívelo para evitar por completo los límites de velocidad de CivArchive.",
|
||||
"enableOpenmodeldbApi": "Habilitar OpenModelDB como proveedor de metadatos",
|
||||
"enableOpenmodeldbApiHelp": "Al activarlo, los metadatos de Upscaler también se consultan en el catálogo de OpenModelDB (openmodeldb.info) cuando CivitAI no tiene ningún registro. El catálogo se almacena en caché localmente y se actualiza a diario.",
|
||||
"providerOrder": "Orden de proveedores de metadatos de respaldo",
|
||||
"providerOrderHelp": "La API de CivitAI siempre se intenta primero. Elija el orden de los demás proveedores al buscar metadatos.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Modo de almacenamiento de archivos sidecar",
|
||||
"modeHelp": "Elige dónde se guardan los archivos sidecar .metadata.json y las imágenes de vista previa: junto a cada archivo de modelo, o en un único directorio centralizado que refleja la estructura de tu biblioteca. Los archivos .civitai.info siempre permanecen junto al archivo de modelo.",
|
||||
"modeOptions": {
|
||||
"alongside": "Junto a los archivos de modelo (predeterminado)",
|
||||
"centralized": "Almacenamiento centralizado"
|
||||
},
|
||||
"path": "Ruta del almacenamiento centralizado",
|
||||
"pathHelp": "Carpeta raíz del almacenamiento centralizado de archivos sidecar. Déjalo vacío para usar la ubicación predeterminada (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Vacío = <settings dir>/sidecars",
|
||||
"management": "Migración de archivos sidecar",
|
||||
"managementHelp": "Mueve los archivos sidecar .metadata.json y las imágenes de vista previa existentes entre el almacenamiento junto a los modelos y el almacenamiento centralizado, según el modo seleccionado. Cambiar el modo no mueve los archivos existentes automáticamente.",
|
||||
"migrateButton": "Migrar archivos sidecar ahora",
|
||||
"migratingButton": "Migrando...",
|
||||
"migrating": "Migrando archivos sidecar...",
|
||||
"migrateFailed": "Error al migrar los archivos sidecar: {message}",
|
||||
"migrationDeferred": "Los archivos sidecar existentes no se movieron. Puedes migrarlos más tarde desde Configuración → Biblioteca → Almacenamiento de archivos sidecar.",
|
||||
"confirmToCentralized": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Moverlos ahora al directorio de almacenamiento centralizado? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
|
||||
"confirmToAlongside": "El modo de almacenamiento ha cambiado, pero los archivos sidecar .metadata.json y las imágenes de vista previa existentes no se mueven automáticamente. ¿Devolverlos ahora junto a sus archivos de modelo? También puedes hacerlo más tarde con el botón «Migrar archivos sidecar ahora».",
|
||||
"confirmToCentralizedManual": "¿Mover ahora los archivos sidecar .metadata.json y las imágenes de vista previa existentes al directorio de almacenamiento centralizado?",
|
||||
"confirmToAlongsideManual": "¿Devolver ahora los archivos sidecar .metadata.json y las imágenes de vista previa existentes junto a sus archivos de modelo?",
|
||||
"confirmRelocateRoot": "El directorio de almacenamiento centralizado ha cambiado, pero los archivos sidecar y las imágenes de vista previa existentes siguen en el directorio anterior. ¿Moverlos ahora al nuevo directorio?",
|
||||
"effectivePathLabel": "Ubicación de almacenamiento efectiva:",
|
||||
"openFolderButton": "Abrir carpeta",
|
||||
"repoWarning": "La ubicación de almacenamiento efectiva está dentro de la carpeta de instalación de LoRA Manager. Reinstalar el plugin o una actualización limpia puede eliminarla — define una ruta de almacenamiento explícita fuera de la carpeta de instalación.",
|
||||
"openLocationSuccess": "Carpeta de almacenamiento de archivos sidecar abierta",
|
||||
"openLocationCopied": "Ruta de almacenamiento de archivos sidecar copiada al portapapeles: {path}",
|
||||
"openLocationClipboardFallback": "Copia manualmente la ruta de almacenamiento de archivos sidecar: {path}",
|
||||
"openLocationFailed": "No se pudo abrir la carpeta de almacenamiento de archivos sidecar"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Habilitar proxy a nivel de aplicación",
|
||||
"enableProxyHelp": "Habilita la configuración de proxy personalizada para esta aplicación, sobrescribiendo la configuración de proxy del sistema",
|
||||
@@ -861,7 +922,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",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "Enviar workflow a ComfyUI",
|
||||
"sent": "Workflow enviado a ComfyUI",
|
||||
"sendFailed": "Error al enviar el workflow a ComfyUI",
|
||||
"noWorkflow": "No se encontró ningún workflow integrado en esta receta"
|
||||
"noWorkflow": "No se encontró ningún workflow integrado en esta receta",
|
||||
"hasWorkflow": "Contiene un workflow de ComfyUI"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Listo para usar",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "Esta carpeta no contiene modelos. Los demás archivos que contenga también se eliminarán.",
|
||||
"notEmptyTitle": "La carpeta no está vacía",
|
||||
"notEmptyMessage": "Esta carpeta aún contiene modelos. Elimínalos o muévelos primero — eliminar una carpeta nunca elimina los archivos de modelo en cascada.",
|
||||
"confirm": "Eliminar carpeta"
|
||||
"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",
|
||||
"confirmMulti": "Eliminar {count} carpetas",
|
||||
"deleting": "Eliminando...",
|
||||
"keptNote": "Las copias no marcadas mantienen esta carpeta en la barra lateral."
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Carpeta \"{name}\" eliminada",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "Carpeta restaurada",
|
||||
"failed": "Error al eliminar la carpeta: {message}",
|
||||
"notEmpty": "Esta carpeta aún contiene modelos. Actualiza la barra lateral e inténtalo de nuevo.",
|
||||
"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": {
|
||||
@@ -1369,7 +1451,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.",
|
||||
@@ -1389,6 +1490,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": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "Selecciona un directorio raíz",
|
||||
"selectModelRoot": "Seleccionar raíz del modelo:",
|
||||
"selectTypeRoot": "Seleccionar raíz de {type}:",
|
||||
"routingOverride": {
|
||||
"label": "Tipo de destino:",
|
||||
"tooltip": "Detectado automáticamente a partir de los metadatos del modelo. Cámbialo si la detección fue incorrecta; cambiar desactiva «Usar ruta predeterminada» para esta descarga."
|
||||
},
|
||||
"targetFolderPath": "Ruta de carpeta de destino:",
|
||||
"browseFolders": "Explorar carpetas:",
|
||||
"createNewFolder": "Crear nueva carpeta",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "¿Restaurar los nombres de archivo originales?",
|
||||
"revertButton": "Restaurar nombres de archivo originales"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "¿Mover los archivos sidecar al almacenamiento centralizado?",
|
||||
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
|
||||
"confirmButton": "Migrar ahora",
|
||||
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
|
||||
"destination": "Destino: {path}",
|
||||
"defaultLocationHint": "Se usará la ubicación predeterminada. Para usar una carpeta personalizada, cancela, define la ruta de almacenamiento arriba y luego haz clic en «Migrar archivos sidecar ahora»."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Resumen de la migración de archivos sidecar",
|
||||
"completedSuccessfully": "Migración completada correctamente",
|
||||
"completedWithErrors": "Completado con {count} error(es)",
|
||||
"statMoved": "Archivos movidos",
|
||||
"statModels": "Modelos",
|
||||
"statSkipped": "Omitidos",
|
||||
"statConflicts": "Conflictos resueltos",
|
||||
"statErrors": "Errores",
|
||||
"failedItems": "Elementos fallidos ({count})",
|
||||
"columnModel": "Modelo",
|
||||
"columnError": "Error",
|
||||
"successMessage": "Se movieron {moved} archivos para {models} modelos",
|
||||
"location": "Ubicación de almacenamiento: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "Ver en Hugging Face",
|
||||
"viewCreatorProfile": "Ver perfil del creador",
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
"openSidecarLocation": "Abrir ubicación de los metadatos",
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
"sendToWorkflowText": "Enviar a ComfyUI",
|
||||
"copyHash": "Copiar hash",
|
||||
"copyCivitaiId": "Copiar ID de Civitai",
|
||||
"civitaiIdCopied": "ID de Civitai copiado al portapapeles",
|
||||
"deleteModelWithShortcut": "Eliminar modelo (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Ubicación de los metadatos abierta exitosamente",
|
||||
"failed": "Error al abrir la ubicación de los metadatos",
|
||||
"copied": "Ruta copiada al portapapeles: {{path}}",
|
||||
"clipboardFallback": "Ruta: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "No se puede enviar a ComfyUI: no hay ruta de archivo disponible"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "Modelo base",
|
||||
"size": "Tamaño",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "ID del modelo",
|
||||
"civitaiVersionId": "ID de versión",
|
||||
"unknown": "Desconocido",
|
||||
"usageTips": "Consejos de uso",
|
||||
"additionalNotes": "Notas adicionales",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "Contenido para adultos",
|
||||
"nsfwR": "Contenido clasificación R",
|
||||
"nsfwX": "Contenido clasificación X",
|
||||
"nsfwXxx": "Contenido clasificación XXX"
|
||||
"nsfwXxx": "Contenido clasificación XXX",
|
||||
"layoutGallery": "Vista de galería",
|
||||
"layoutList": "Vista de lista"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versiones del modelo",
|
||||
@@ -1935,10 +2080,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",
|
||||
@@ -1993,6 +2143,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!",
|
||||
@@ -2677,7 +2828,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.",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Conflictos de nombres de archivo duplicados"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Archivos sidecar centralizados"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "Versión de la interfaz"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "Nom du modèle",
|
||||
"tags": "Tags",
|
||||
"creator": "Créateur",
|
||||
"hash": "Hash",
|
||||
"title": "Titre de la recipe",
|
||||
"loraName": "Nom de fichier LoRA",
|
||||
"loraModel": "Nom du modèle LoRA",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "Disponibilité des LoRAs",
|
||||
"availabilityReady": "Prêts à l'emploi",
|
||||
"availabilityMissing": "Avec LoRAs manquants",
|
||||
"availabilityDeleted": "Avec LoRAs supprimés"
|
||||
"availabilityDeleted": "Avec LoRAs supprimés",
|
||||
"workflow": "Workflow",
|
||||
"hasWorkflow": "Avec Workflow"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Basculer le thème",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
|
||||
},
|
||||
"aria2HelpLink": "Apprenez à configurer le backend de téléchargement aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Routage des modèles de base inconnus",
|
||||
"help": "Détermine où vont les téléchargements de Checkpoint lorsque CivitAI signale un modèle de base qui n’est ni un Checkpoint connu (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ni un modèle de diffusion connu. De nouvelles architectures de diffusion apparaissent fréquemment, donc les router vers les modèles de diffusion est généralement correct.",
|
||||
"options": {
|
||||
"diffusionModel": "Modèles de diffusion",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Préférence d’hôte CivitAI disponible",
|
||||
"content": "CivitAI utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
|
||||
@@ -380,9 +394,11 @@
|
||||
"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",
|
||||
"sidecarStorage": "Stockage des fichiers sidecar",
|
||||
"proxySettings": "Paramètres du proxy"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "Révéler au survol"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||
"showcaseLayout": "Disposition des images d'exemple",
|
||||
"showcaseLayoutHelp": "Choisissez comment les images d'exemple sont affichées dans la fenêtre modale du modèle : une galerie une image à la fois ou la liste verticale classique.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Galerie",
|
||||
"vertical": "Liste verticale"
|
||||
},
|
||||
"showVersionOnCard": "Afficher la version sur la carte",
|
||||
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
|
||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||
@@ -745,6 +767,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."
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "Téléchargement terminé avec succès",
|
||||
"enableCivarchiveApi": "Activer l'API CivArchive comme fournisseur de métadonnées",
|
||||
"enableCivarchiveApiHelp": "Lorsqu'elle est activée, l'API CivArchive est utilisée comme source de secours pour les métadonnées des modèles (par ex. pour les modèles supprimés de CivitAI). Désactivez pour éviter entièrement les limites de débit de CivArchive.",
|
||||
"enableOpenmodeldbApi": "Activer OpenModelDB comme fournisseur de métadonnées",
|
||||
"enableOpenmodeldbApiHelp": "Lorsqu’elle est activée, les métadonnées des Upscaler sont également recherchées dans le catalogue OpenModelDB (openmodeldb.info) lorsque CivitAI n’a aucune fiche. Le catalogue est mis en cache localement et actualisé quotidiennement.",
|
||||
"providerOrder": "Ordre de secours des fournisseurs de métadonnées",
|
||||
"providerOrderHelp": "L'API CivitAI est toujours essayée en premier. Choisissez l'ordre des autres fournisseurs lors de la recherche de métadonnées.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Mode de stockage des fichiers sidecar",
|
||||
"modeHelp": "Choisissez où sont stockés les fichiers sidecar .metadata.json et les images d’aperçu : à côté de chaque fichier de modèle, ou dans un seul dossier centralisé qui reflète la structure de votre bibliothèque. Les fichiers .civitai.info restent toujours à côté du fichier de modèle.",
|
||||
"modeOptions": {
|
||||
"alongside": "À côté des fichiers de modèle (par défaut)",
|
||||
"centralized": "Stockage centralisé"
|
||||
},
|
||||
"path": "Chemin du stockage centralisé",
|
||||
"pathHelp": "Dossier racine du stockage centralisé des fichiers sidecar. Laissez vide pour utiliser l’emplacement par défaut (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Vide = <settings dir>/sidecars",
|
||||
"management": "Migration des fichiers sidecar",
|
||||
"managementHelp": "Déplacez les fichiers sidecar .metadata.json et les images d’aperçu existants entre le stockage à côté des modèles et le stockage centralisé, selon le mode sélectionné. Changer de mode ne déplace pas les fichiers existants automatiquement.",
|
||||
"migrateButton": "Migrer les fichiers sidecar maintenant",
|
||||
"migratingButton": "Migration en cours...",
|
||||
"migrating": "Migration des fichiers sidecar en cours...",
|
||||
"migrateFailed": "Échec de la migration des fichiers sidecar : {message}",
|
||||
"migrationDeferred": "Les fichiers sidecar existants n’ont pas été déplacés. Vous pouvez les migrer plus tard depuis Paramètres → Bibliothèque → Stockage des fichiers sidecar.",
|
||||
"confirmToCentralized": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les déplacer maintenant dans le dossier de stockage centralisé ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
|
||||
"confirmToAlongside": "Le mode de stockage a changé, mais les fichiers sidecar .metadata.json et les images d’aperçu existants ne sont pas déplacés automatiquement. Les remettre maintenant à côté de leurs fichiers de modèle ? Vous pouvez aussi le faire plus tard avec le bouton « Migrer les fichiers sidecar maintenant ».",
|
||||
"confirmToCentralizedManual": "Déplacer maintenant les fichiers sidecar .metadata.json et les images d’aperçu existants dans le dossier de stockage centralisé ?",
|
||||
"confirmToAlongsideManual": "Remettre maintenant les fichiers sidecar .metadata.json et les images d’aperçu existants à côté de leurs fichiers de modèle ?",
|
||||
"confirmRelocateRoot": "Le dossier de stockage centralisé a changé, mais les fichiers sidecar et les images d’aperçu existants se trouvent encore dans le dossier précédent. Les déplacer maintenant dans le nouveau dossier ?",
|
||||
"effectivePathLabel": "Emplacement de stockage effectif :",
|
||||
"openFolderButton": "Ouvrir le dossier",
|
||||
"repoWarning": "L’emplacement de stockage effectif se trouve dans le dossier d’installation de LoRA Manager. Une réinstallation de l’extension ou une mise à jour propre peut le supprimer — définissez un chemin de stockage explicite en dehors du dossier d’installation.",
|
||||
"openLocationSuccess": "Dossier de stockage des fichiers sidecar ouvert",
|
||||
"openLocationCopied": "Chemin de stockage des fichiers sidecar copié dans le presse-papiers : {path}",
|
||||
"openLocationClipboardFallback": "Copiez manuellement le chemin de stockage des fichiers sidecar : {path}",
|
||||
"openLocationFailed": "Échec de l’ouverture du dossier de stockage des fichiers sidecar"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Activer le proxy au niveau de l'application",
|
||||
"enableProxyHelp": "Activer les paramètres de proxy personnalisés pour cette application, remplaçant les paramètres de proxy système",
|
||||
@@ -861,7 +922,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",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
|
||||
"sent": "Workflow envoyé vers ComfyUI",
|
||||
"sendFailed": "Échec de l'envoi du workflow vers ComfyUI",
|
||||
"noWorkflow": "Aucun workflow intégré trouvé dans cette Recipe"
|
||||
"noWorkflow": "Aucun workflow intégré trouvé dans cette Recipe",
|
||||
"hasWorkflow": "Contient un Workflow ComfyUI"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Prêt à l'emploi",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "Ce dossier ne contient aucun modèle. Les autres fichiers qu’il contient seront également supprimés.",
|
||||
"notEmptyTitle": "Le dossier n’est pas vide",
|
||||
"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.",
|
||||
"confirm": "Supprimer le dossier"
|
||||
"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",
|
||||
"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é",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "Dossier restauré",
|
||||
"failed": "Échec de la suppression du dossier : {message}",
|
||||
"notEmpty": "Ce dossier contient encore des modèles. Actualisez la barre latérale et réessayez.",
|
||||
"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": {
|
||||
@@ -1369,7 +1451,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.",
|
||||
@@ -1389,6 +1490,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": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "Sélectionner un répertoire racine",
|
||||
"selectModelRoot": "Sélectionner la racine du modèle :",
|
||||
"selectTypeRoot": "Sélectionner la racine {type} :",
|
||||
"routingOverride": {
|
||||
"label": "Type de destination :",
|
||||
"tooltip": "Détecté automatiquement à partir des métadonnées du modèle. Changez si la détection est incorrecte ; changer désactive « Utiliser le chemin par défaut » pour ce téléchargement."
|
||||
},
|
||||
"targetFolderPath": "Chemin du dossier cible :",
|
||||
"browseFolders": "Parcourir les dossiers :",
|
||||
"createNewFolder": "Créer un nouveau dossier",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "Restaurer les noms de fichier d'origine ?",
|
||||
"revertButton": "Restaurer les noms de fichier d'origine"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Déplacer les fichiers sidecar vers le stockage centralisé ?",
|
||||
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
|
||||
"confirmButton": "Migrer maintenant",
|
||||
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
|
||||
"destination": "Destination : {path}",
|
||||
"defaultLocationHint": "L’emplacement par défaut sera utilisé. Pour utiliser un dossier personnalisé, annulez, définissez le chemin de stockage ci-dessus, puis cliquez sur « Migrer les fichiers sidecar maintenant »."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Résumé de la migration des fichiers sidecar",
|
||||
"completedSuccessfully": "Migration terminée avec succès",
|
||||
"completedWithErrors": "Terminé avec {count} erreur(s)",
|
||||
"statMoved": "Fichiers déplacés",
|
||||
"statModels": "Modèles",
|
||||
"statSkipped": "Ignorés",
|
||||
"statConflicts": "Conflits résolus",
|
||||
"statErrors": "Erreurs",
|
||||
"failedItems": "Éléments en échec ({count})",
|
||||
"columnModel": "Modèle",
|
||||
"columnError": "Erreur",
|
||||
"successMessage": "{moved} fichiers déplacés pour {models} modèles",
|
||||
"location": "Emplacement de stockage : {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "Voir sur Hugging Face",
|
||||
"viewCreatorProfile": "Voir le profil du créateur",
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||
"openSidecarLocation": "Ouvrir l'emplacement des métadonnées",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
||||
"copyHash": "Copier le hash",
|
||||
"copyCivitaiId": "Copier l’ID Civitai",
|
||||
"civitaiIdCopied": "ID Civitai copié dans le presse-papiers",
|
||||
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||
"clipboardFallback": "Chemin: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Emplacement des métadonnées ouvert avec succès",
|
||||
"failed": "Échec de l'ouverture de l'emplacement des métadonnées",
|
||||
"copied": "Chemin copié dans le presse-papiers: {{path}}",
|
||||
"clipboardFallback": "Chemin: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Impossible d'envoyer vers ComfyUI : aucun chemin de fichier disponible"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "Modèle de base",
|
||||
"size": "Taille",
|
||||
"hashes": "Hashes",
|
||||
"civitaiModelId": "ID du modèle",
|
||||
"civitaiVersionId": "ID de version",
|
||||
"unknown": "Inconnu",
|
||||
"usageTips": "Conseils d'utilisation",
|
||||
"additionalNotes": "Notes supplémentaires",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "Contenu pour adultes",
|
||||
"nsfwR": "Contenu classé R",
|
||||
"nsfwX": "Contenu classé X",
|
||||
"nsfwXxx": "Contenu classé XXX"
|
||||
"nsfwXxx": "Contenu classé XXX",
|
||||
"layoutGallery": "Vue galerie",
|
||||
"layoutList": "Vue liste"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versions du modèle",
|
||||
@@ -1935,10 +2080,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",
|
||||
@@ -1993,6 +2143,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 !",
|
||||
@@ -2677,7 +2828,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.",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Conflits de noms de fichiers en double"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Fichiers sidecar centralisés"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "Version de l'interface"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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": "נקה תיקיות תמונות דוגמה",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "שם מודל",
|
||||
"tags": "תגיות",
|
||||
"creator": "יוצר",
|
||||
"hash": "hash",
|
||||
"title": "כותרת מתכון",
|
||||
"loraName": "שם קובץ LoRA",
|
||||
"loraModel": "שם מודל LoRA",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "זמינות LoRA",
|
||||
"availabilityReady": "מוכנים לשימוש",
|
||||
"availabilityMissing": "עם LoRAs חסרים",
|
||||
"availabilityDeleted": "עם LoRAs שנמחקו"
|
||||
"availabilityDeleted": "עם LoRAs שנמחקו",
|
||||
"workflow": "workflow",
|
||||
"hasWorkflow": "עם workflow"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "החלף ערכת נושא",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
|
||||
},
|
||||
"aria2HelpLink": "למד כיצד להגדיר את מנוע ההורדה aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "ניתוב מודל בסיס לא מוכר",
|
||||
"help": "קובע לאן מועברות הורדות של Checkpoint כאשר CivitAI מדווח על מודל בסיס שהוא לא Checkpoint מוכר (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI) ולא מודל דיפוזיה מוכר. ארכיטקטורות דיפוזיה חדשות מופיעות לעיתים קרובות, ולכן ניתוב שלהן למודלי דיפוזיה הוא בדרך כלל הנכון.",
|
||||
"options": {
|
||||
"diffusionModel": "מודלי דיפוזיה",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "העדפת מארח CivitAI זמינה",
|
||||
"content": "CivitAI משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
"versionScope": "תגי עדכון",
|
||||
"priceTracking": "מחירי הורדה ב-Buzz",
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"autoOrganize": "ארגון אוטומטי",
|
||||
"metadata": "מטא-נתונים",
|
||||
"sidecarStorage": "אחסון קובצי לוואי",
|
||||
"proxySettings": "הגדרות פרוקסי"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "חשוף בריחוף"
|
||||
},
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||
"showcaseLayout": "פריסת תמונות דוגמה",
|
||||
"showcaseLayoutHelp": "בחר כיצד יוצגו תמונות הדוגמה בחלון המודל: גלריה המציגה תמונה אחת בכל פעם או הרשימה האנכית הקלאסית.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "גלריה",
|
||||
"vertical": "רשימה אנכית"
|
||||
},
|
||||
"showVersionOnCard": "הצג גרסה בכרטיס",
|
||||
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
|
||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||
@@ -745,6 +767,13 @@
|
||||
"label": "הסתר עדכונים בתשלום",
|
||||
"help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'"
|
||||
},
|
||||
"priceTracking": {
|
||||
"help": "ה-API הציבורי של CivitAI אינו מפרסם מחירי הורדה, ולכן הם נקראים מדף המודל - בקשה נוספת אחת לכל מודל בתשלום, ברענון לפי המרווח שלמטה. המחירים מוצגים רק בגרסאות שאין לך עדיין.",
|
||||
"enabled": "הצג מחירי הורדה של גרסאות בתשלום",
|
||||
"enabledHelp": "קורא את מחיר ההורדה מדף המודל כדי להציג כמה עולות גרסאות בתשלום וגרסאות בגישה מוקדמת, ומתי מסתיימת הגישה המוקדמת",
|
||||
"refreshHours": "מרווח רענון המחירים (שעות)",
|
||||
"refreshHoursHelp": "כמה זמן מחיר שמור נחשב מעודכן לפני קריאה חוזרת"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "ההורדה הושלמה בהצלחה",
|
||||
"enableCivarchiveApi": "הפעל את CivArchive API כספק מטא-נתונים",
|
||||
"enableCivarchiveApiHelp": "כאשר מופעל, CivArchive API משמש כמקור גיבוי למטא-נתונים של מודלים (למשל עבור מודלים שנמחקו מ-CivitAI). כבה כדי להימנע לחלוטין ממגבלות הקצב של CivArchive.",
|
||||
"enableOpenmodeldbApi": "הפעל את OpenModelDB כספק מטא-נתונים",
|
||||
"enableOpenmodeldbApiHelp": "כאשר מופעל, מטא-נתונים של Upscaler מחופשים גם בקטלוג OpenModelDB (openmodeldb.info) כאשר ל-CivitAI אין רשומה. הקטלוג נשמר במטמון מקומי ומתרענן מדי יום.",
|
||||
"providerOrder": "סדר ספקי מטא-נתונים לגיבוי",
|
||||
"providerOrderHelp": "CivitAI API תמיד מנוסה ראשון. בחר את סדר הספקים הנותרים בעת חיפוש מטא-נתונים.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "מצב אחסון קובצי לוואי",
|
||||
"modeHelp": "בחר היכן יאוחסנו קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה: לצד כל קובץ מודל, או בתיקייה מרכזית אחת שמשקפת את מבנה הספרייה שלך. קובצי .civitai.info נשארים תמיד לצד קובץ המודל.",
|
||||
"modeOptions": {
|
||||
"alongside": "לצד קובצי המודלים (ברירת מחדל)",
|
||||
"centralized": "אחסון מרכזי"
|
||||
},
|
||||
"path": "נתיב האחסון המרכזי",
|
||||
"pathHelp": "תיקיית השורש של אחסון קובצי הלוואי המרכזי. השאר ריק כדי להשתמש במיקום ברירת המחדל <settings dir>/sidecars.",
|
||||
"pathPlaceholder": "ריק = <settings dir>/sidecars",
|
||||
"management": "העברת קובצי לוואי",
|
||||
"managementHelp": "העבר קובצי לוואי .metadata.json ותמונות תצוגה מקדימה קיימים בין אחסון לצד המודלים לאחסון המרכזי, בהתאם למצב שנבחר. שינוי המצב אינו מעביר קבצים קיימים באופן אוטומטי.",
|
||||
"migrateButton": "העבר קובצי לוואי כעת",
|
||||
"migratingButton": "מעביר...",
|
||||
"migrating": "מעביר קובצי לוואי...",
|
||||
"migrateFailed": "העברת קובצי הלוואי נכשלה: {message}",
|
||||
"migrationDeferred": "קובצי הלוואי הקיימים לא הועברו. ניתן להעביר אותם מאוחר יותר דרך הגדרות > ספרייה > אחסון קובצי לוואי.",
|
||||
"confirmToCentralized": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להעביר אותם כעת לתיקיית האחסון המרכזי? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
|
||||
"confirmToAlongside": "מצב האחסון השתנה, אך קובצי הלוואי .metadata.json ותמונות התצוגה המקדימה הקיימים אינם מועברים אוטומטית. להחזיר אותם כעת לצד קובצי המודל שלהם? ניתן לעשות זאת גם מאוחר יותר באמצעות הכפתור «העבר קובצי לוואי כעת».",
|
||||
"confirmToCentralizedManual": "להעביר כעת את קובצי הלוואי .metadata.json ואת תמונות התצוגה המקדימה הקיימים לתיקיית האחסון המרכזי?",
|
||||
"confirmToAlongsideManual": "להחזיר כעת את קובצי הלוואי .metadata.json ואת תמונות התצוגה המקדימה הקיימים לצד קובצי המודל שלהם?",
|
||||
"confirmRelocateRoot": "תיקיית האחסון המרכזי השתנתה, אך קובצי הלוואי ותמונות התצוגה המקדימה הקיימים עדיין נמצאים בתיקייה הקודמת. להעביר אותם כעת לתיקייה החדשה?",
|
||||
"effectivePathLabel": "מיקום האחסון בפועל:",
|
||||
"openFolderButton": "פתח תיקייה",
|
||||
"repoWarning": "מיקום האחסון בפועל נמצא בתוך תיקיית ההתקנה של LoRA Manager. התקנה מחדש של התוסף או עדכון נקי עלולים למחוק אותו — הגדר נתיב אחסון מפורש מחוץ לתיקיית ההתקנה.",
|
||||
"openLocationSuccess": "תיקיית אחסון קובצי הלוואי נפתחה",
|
||||
"openLocationCopied": "נתיב אחסון קובצי הלוואי הועתק ללוח: {path}",
|
||||
"openLocationClipboardFallback": "העתק את נתיב אחסון קובצי הלוואי ידנית: {path}",
|
||||
"openLocationFailed": "פתיחת תיקיית אחסון קובצי הלוואי נכשלה"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "הפעל פרוקסי ברמת האפליקציה",
|
||||
"enableProxyHelp": "אפשר הגדרות פרוקסי מותאמות אישית עבור יישום זה, במקום הגדרות הפרוקסי של המערכת",
|
||||
@@ -861,7 +922,10 @@
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מודלים",
|
||||
"full": "בניית מטמון מחדש",
|
||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-נתונים – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
|
||||
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-נתונים – לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות.",
|
||||
"scopeSection": "סרוק תיקייה אחת בלבד",
|
||||
"rootOffline": "לא זמין",
|
||||
"rootModels": "{count} מודלים"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "אחזר מטא-נתונים מ-CivitAI",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "שלח workflow ל-ComfyUI",
|
||||
"sent": "ה-workflow נשלח ל-ComfyUI",
|
||||
"sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה",
|
||||
"noWorkflow": "לא נמצא workflow מוטבע במתכון זה"
|
||||
"noWorkflow": "לא נמצא workflow מוטבע במתכון זה",
|
||||
"hasWorkflow": "מכיל workflow של ComfyUI"
|
||||
},
|
||||
"status": {
|
||||
"ready": "מוכן לשימוש",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "אין מודלים בתיקייה זו. קבצים אחרים שבה יימחקו גם הם.",
|
||||
"notEmptyTitle": "התיקייה אינה ריקה",
|
||||
"notEmptyMessage": "בתיקייה זו עדיין יש מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"confirm": "מחק תיקייה"
|
||||
"notEmptyMessageCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. מחק או העבר אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"notEmptyMessageExcluded": "בתיקייה זו עדיין יש {count} קובצי מודלים, שמהם {excluded} מוחרגים מהספרייה. בטל את ההחרגה ב«ניהול מודלים מוחרגים» ומחק אותם תחילה — מחיקת תיקייה לעולם אינה מוחקת קובצי מודלים.",
|
||||
"missingTitle": "התיקייה לא נמצאה",
|
||||
"missingMessage": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
|
||||
"symlinkTitle": "התיקייה היא קישור סמלי",
|
||||
"symlinkMessage": "כל העותקים של תיקייה זו הם קישורים סמליים; מחק את הקישור או את היעד שלו מחוץ ל-LoRA Manager.",
|
||||
"protectedTitle": "תיקייה מוגנת",
|
||||
"protectedMessage": "תיקייה זו מכילה את ספריית המתכונים ולא ניתן למחוק אותה מתוך LoRA Manager.",
|
||||
"busyTitle": "מחיקה עדיין ממתינה",
|
||||
"checking": "בודק את תוכן התיקייה...",
|
||||
"confirm": "מחק תיקייה",
|
||||
"confirmMulti": "מחק {count} תיקיות",
|
||||
"deleting": "מוחק...",
|
||||
"keptNote": "עותקים שלא סומנו ישאירו את התיקייה הזו בסרגל הצד."
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "התיקייה \"{name}\" נמחקה",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "התיקייה שוחזרה",
|
||||
"failed": "מחיקת התיקייה נכשלה: {message}",
|
||||
"notEmpty": "בתיקייה זו עדיין יש מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"notEmptyWithCount": "בתיקייה זו עדיין יש {count} קובצי מודלים. רענן את סרגל הצד ונסה שוב.",
|
||||
"busy": "מחיקה עדיין ממתינה בתיקייה זו. המתן לסיום חלון הביטול.",
|
||||
"unsupported": "מחיקת תיקיות אינה נתמכת בדף זה",
|
||||
"noRoot": "לא הוגדר שורש מודלים"
|
||||
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב.",
|
||||
"symlink": "תיקייה זו היא קישור סמלי; לא ניתן למחוק אותה מסרגל הצד.",
|
||||
"successMulti": "התיקייה \"{name}\" נמחקה מ-{count} שורשי מודלים",
|
||||
"partial": "נמחקו {count} מתוך {total} עותקים — {failed} לא נמחקו. רענן את סרגל הצד ונסה שוב."
|
||||
},
|
||||
"renameFolder": "שנה שם תיקייה",
|
||||
"renameFolderResult": {
|
||||
@@ -1369,7 +1451,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": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
@@ -1389,6 +1490,10 @@
|
||||
"success": "נמצאו {count} עדכון/ים עבור {type}s בתיקייה זו",
|
||||
"none": "כל ה-{type}s בתיקייה זו מעודכנים",
|
||||
"error": "נכשל בבדיקת עדכוני {type} בתיקייה: {message}"
|
||||
},
|
||||
"scanFolder": "סרוק תיקייה זו",
|
||||
"scanFolderResult": {
|
||||
"missing": "תיקייה זו אינה קיימת עוד בדיסק. רענן את סרגל הצד ונסה שוב."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "בחר ספריית שורש",
|
||||
"selectModelRoot": "בחר שורש מודל:",
|
||||
"selectTypeRoot": "בחר שורש {type}:",
|
||||
"routingOverride": {
|
||||
"label": "סוג יעד:",
|
||||
"tooltip": "זוהה אוטומטית ממטא-נתוני המודל. החלף אם הזיהוי שגוי; החלפה מכבה את «השתמש בנתיב ברירת מחדל» עבור הורדה זו."
|
||||
},
|
||||
"targetFolderPath": "נתיב תיקיית יעד:",
|
||||
"browseFolders": "דפדף בתיקיות:",
|
||||
"createNewFolder": "צור תיקייה חדשה",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "לשחזר שמות קבצים מקוריים?",
|
||||
"revertButton": "שחזר שמות קבצים מקוריים"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "להעביר את קובצי הלוואי לאחסון המרכזי?",
|
||||
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
|
||||
"confirmButton": "העבר כעת",
|
||||
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
|
||||
"destination": "יעד: {path}",
|
||||
"defaultLocationHint": "נעשה שימוש במיקום ברירת המחדל. כדי להשתמש בתיקייה מותאמת אישית, בטל, הגדר את נתיב האחסון למעלה ולאחר מכן לחץ על «העבר קובצי לוואי כעת»."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "סיכום העברת קובצי לוואי",
|
||||
"completedSuccessfully": "ההעברה הושלמה בהצלחה",
|
||||
"completedWithErrors": "הושלם עם {count} שגיאות",
|
||||
"statMoved": "קבצים שהועברו",
|
||||
"statModels": "מודלים",
|
||||
"statSkipped": "דולגו",
|
||||
"statConflicts": "קונפליקטים שנפתרו",
|
||||
"statErrors": "שגיאות",
|
||||
"failedItems": "פריטים שנכשלו ({count})",
|
||||
"columnModel": "מודל",
|
||||
"columnError": "שגיאה",
|
||||
"successMessage": "הועברו {moved} קבצים עבור {models} מודלים",
|
||||
"location": "מיקום אחסון: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "הוסף תגיות למספר מודלים",
|
||||
"description": "הוסף תגיות ל-",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "צפייה ב-Hugging Face",
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"openSidecarLocation": "פתח מיקום מטא-נתונים",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI",
|
||||
"copyHash": "העתק hash",
|
||||
"copyCivitaiId": "העתק מזהה Civitai",
|
||||
"civitaiIdCopied": "מזהה Civitai הועתק ללוח העריכה",
|
||||
"deleteModelWithShortcut": "מחק מודל (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "מיקום המטא-נתונים נפתח בהצלחה",
|
||||
"failed": "פתיחת מיקום המטא-נתונים נכשלה",
|
||||
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
|
||||
"clipboardFallback": "נתיב: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "לא ניתן לשלוח ל-ComfyUI: אין נתיב קובץ זמין"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "מודל בסיס",
|
||||
"size": "גודל",
|
||||
"hashes": "hashes",
|
||||
"civitaiModelId": "מזהה מודל",
|
||||
"civitaiVersionId": "מזהה גרסה",
|
||||
"unknown": "לא ידוע",
|
||||
"usageTips": "טיפים לשימוש",
|
||||
"additionalNotes": "הערות נוספות",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "תוכן למבוגרים",
|
||||
"nsfwR": "תוכן בדירוג R",
|
||||
"nsfwX": "תוכן בדירוג X",
|
||||
"nsfwXxx": "תוכן בדירוג XXX"
|
||||
"nsfwXxx": "תוכן בדירוג XXX",
|
||||
"layoutGallery": "תצוגת גלריה",
|
||||
"layoutList": "תצוגת רשימה"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "גרסאות המודל",
|
||||
@@ -1935,10 +2080,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": "הורדה",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "הצלחה",
|
||||
"statFailed": "נכשל",
|
||||
"statSkipped": "דולג",
|
||||
"statOffline": "כונן לא זמין",
|
||||
"statTotal": "סה\"כ נסרק",
|
||||
"statDuration": "משך",
|
||||
"successMessage": "כל {count} {type}s עודכנו בהצלחה!",
|
||||
@@ -2677,7 +2828,10 @@
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||
"moveFailed": "לא ניתן היה להעביר את הפריט: {message}",
|
||||
"copiedToClipboard": "הועתק ללוח",
|
||||
"downloadStarted": "ההורדה החלה"
|
||||
"downloadStarted": "ההורדה החלה",
|
||||
"refreshCompleteScoped": "{scope} נסרק: {added} חדשים, {removed} הוסרו",
|
||||
"refreshKeptUnreachable": "נשמרו {count} מודלים: {paths} אינם זמינים",
|
||||
"scanRootUnreachable": "{scope} אינו זמין כעת. לא בוצעו שינויים."
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "ספק AI לא הוגדר. הפעל אותו בהגדרות → ספק AI.",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "התנגשויות שמות קבצים כפולים"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "קבצי לוואי מרוכזים"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "גרסת הממשק"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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": "例画像フォルダをクリーンアップ",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "モデル名",
|
||||
"tags": "タグ",
|
||||
"creator": "作成者",
|
||||
"hash": "ハッシュ",
|
||||
"title": "レシピタイトル",
|
||||
"loraName": "LoRAファイル名",
|
||||
"loraModel": "LoRAモデル名",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "LoRA の利用状況",
|
||||
"availabilityReady": "使用可能",
|
||||
"availabilityMissing": "不足 LoRA あり",
|
||||
"availabilityDeleted": "削除済み LoRA あり"
|
||||
"availabilityDeleted": "削除済み LoRA あり",
|
||||
"workflow": "ワークフロー",
|
||||
"hasWorkflow": "ワークフローあり"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "テーマの切り替え",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
|
||||
},
|
||||
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "不明なベースモデルの振り分け",
|
||||
"help": "CivitAIが報告するベースモデルが既知のCheckpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI)でも既知のDiffusion Modelでもない場合に、Checkpointのダウンロードの保存先を決定します。新しい拡散アーキテクチャは頻繁に登場するため、通常はDiffusion Modelへの振り分けが正しい選択です。",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusion Model",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI ホスト設定を利用できます",
|
||||
"content": "CivitAI は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "ダウンロードパステンプレート",
|
||||
"priorityTags": "優先タグ",
|
||||
"versionScope": "アップデートフラグ",
|
||||
"priceTracking": "Buzz ダウンロード価格",
|
||||
"exampleImages": "例画像",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "メタデータ",
|
||||
"sidecarStorage": "サイドカーファイルの保存",
|
||||
"proxySettings": "プロキシ設定"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "ホバー時に表示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||
"showcaseLayout": "ショーケースのレイアウト",
|
||||
"showcaseLayoutHelp": "モデルモーダルでの例画像の表示方法を選択:1枚ずつ表示するギャラリー、または従来の縦並びリスト。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "ギャラリー",
|
||||
"vertical": "縦並びリスト"
|
||||
},
|
||||
"showVersionOnCard": "カードにバージョンを表示",
|
||||
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
|
||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||
@@ -745,6 +767,13 @@
|
||||
"label": "有料更新を非表示",
|
||||
"help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
|
||||
},
|
||||
"priceTracking": {
|
||||
"help": "CivitAI の公開 API はダウンロード価格を提供していないため、モデルページから読み取ります。有料モデルごとに 1 回の追加リクエストが発生し、以下の間隔で更新されます。価格は未ダウンロードのバージョンにのみ表示されます。",
|
||||
"enabled": "有料バージョンのダウンロード価格を表示",
|
||||
"enabledHelp": "モデルページからダウンロード価格を読み取り、有料バージョンと早期アクセスバージョンの費用と早期アクセスの終了時期を表示します",
|
||||
"refreshHours": "価格の更新間隔(時間)",
|
||||
"refreshHoursHelp": "保存された価格が再読み取りまで有効な期間"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "ダウンロードが正常に完了しました",
|
||||
"enableCivarchiveApi": "CivArchive API をメタデータプロバイダーとして有効化",
|
||||
"enableCivarchiveApiHelp": "有効にすると、CivArchive API がモデルメタデータの代替ソースとして使用されます(例:CivitAI から削除されたモデルの場合)。オフにすると、CivArchive のレート制限を完全に回避できます。",
|
||||
"enableOpenmodeldbApi": "OpenModelDB をメタデータプロバイダーとして有効化",
|
||||
"enableOpenmodeldbApiHelp": "有効にすると、CivitAI に記録がない場合に、OpenModelDB カタログ(openmodeldb.info)でも Upscaler のメタデータを検索します。カタログはローカルにキャッシュされ、毎日更新されます。",
|
||||
"providerOrder": "メタデータプロバイダーのフォールバック順序",
|
||||
"providerOrderHelp": "CivitAI API が常に最初に試行されます。メタデータ検索時の残りのプロバイダーの順序を選択してください。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "サイドカーファイルの保存モード",
|
||||
"modeHelp": ".metadata.json サイドカーファイルとプレビュー画像の保存先を選択します。各モデルファイルの隣に保存するか、ライブラリ構造をミラーリングした単一の集中ディレクトリに保存します。.civitai.info ファイルは常にモデルファイルの隣に置かれます。",
|
||||
"modeOptions": {
|
||||
"alongside": "モデルファイルの隣(デフォルト)",
|
||||
"centralized": "集中保存"
|
||||
},
|
||||
"path": "集中保存先パス",
|
||||
"pathHelp": "サイドカーファイルを集中保存するルートディレクトリ。空欄の場合は既定の場所(<settings dir>/sidecars)を使用します。",
|
||||
"pathPlaceholder": "空欄 = <settings dir>/sidecars",
|
||||
"management": "サイドカーファイルの移動",
|
||||
"managementHelp": "既存の .metadata.json サイドカーファイルとプレビュー画像を、現在選択されているモードに合わせて「モデルファイルの隣」と「集中保存」の間で移動します。モードを変更しても既存ファイルは自動では移動しません。",
|
||||
"migrateButton": "今すぐサイドカーファイルを移動",
|
||||
"migratingButton": "移動しています...",
|
||||
"migrating": "サイドカーファイルを移動しています...",
|
||||
"migrateFailed": "サイドカーファイルの移動に失敗しました:{message}",
|
||||
"migrationDeferred": "既存のサイドカーファイルは移動されませんでした。後で「設定 > ライブラリ > サイドカーファイルの保存」から移動できます。",
|
||||
"confirmToCentralized": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ集中保存ディレクトリに移動しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
|
||||
"confirmToAlongside": "保存モードが変更されましたが、既存の .metadata.json サイドカーファイルとプレビュー画像は自動では移動しません。今すぐ各モデルファイルの隣に戻しますか?「今すぐサイドカーファイルを移動」ボタンで後から実行することもできます。",
|
||||
"confirmToCentralizedManual": "既存の .metadata.json サイドカーファイルとプレビュー画像を今すぐ集中保存ディレクトリに移動しますか?",
|
||||
"confirmToAlongsideManual": "既存の .metadata.json サイドカーファイルとプレビュー画像を今すぐ各モデルファイルの隣に戻しますか?",
|
||||
"confirmRelocateRoot": "集中保存ディレクトリが変更されましたが、既存のサイドカーファイルとプレビュー画像はまだ以前のディレクトリにあります。今すぐ新しいディレクトリに移動しますか?",
|
||||
"effectivePathLabel": "実際の保存場所:",
|
||||
"openFolderButton": "フォルダを開く",
|
||||
"repoWarning": "実際の保存場所が LoRA Manager のインストールフォルダ内にあります。プラグインの再インストールやクリーンアップデートで削除される可能性があります。インストールフォルダの外に明示的な保存パスを設定してください。",
|
||||
"openLocationSuccess": "サイドカーファイルの保存フォルダを開きました",
|
||||
"openLocationCopied": "サイドカーファイルの保存パスをクリップボードにコピーしました:{path}",
|
||||
"openLocationClipboardFallback": "サイドカーファイルの保存パスを手動でコピーしてください:{path}",
|
||||
"openLocationFailed": "サイドカーファイルの保存フォルダを開けませんでした"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "アプリレベルのプロキシを有効化",
|
||||
"enableProxyHelp": "このアプリケーション専用のカスタムプロキシ設定を有効にします(システムのプロキシ設定を上書きします)",
|
||||
@@ -861,7 +922,10 @@
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。",
|
||||
"scopeSection": "フォルダを 1 つだけスキャン",
|
||||
"rootOffline": "オフライン",
|
||||
"rootModels": "{count} 個のモデル"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "CivitAIからメタデータを取得",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "ワークフローをComfyUIへ送信",
|
||||
"sent": "ワークフローをComfyUIへ送信しました",
|
||||
"sendFailed": "ワークフローをComfyUIへ送信できませんでした",
|
||||
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
|
||||
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません",
|
||||
"hasWorkflow": "ComfyUI ワークフローを含む"
|
||||
},
|
||||
"status": {
|
||||
"ready": "使用可能",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "このフォルダにはモデルがありません。他のファイルもすべて削除されます。",
|
||||
"notEmptyTitle": "フォルダが空ではありません",
|
||||
"notEmptyMessage": "このフォルダにはまだモデルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"confirm": "フォルダを削除"
|
||||
"notEmptyMessageCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。先に削除するか移動してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"notEmptyMessageExcluded": "このフォルダにはまだ {count} 個のモデルファイルがあり、そのうち {excluded} 個はライブラリから除外されています。「除外モデルを管理」で除外を解除してから削除してください —— フォルダを削除してもモデルファイルがまとめて削除されることはありません。",
|
||||
"missingTitle": "フォルダが見つかりません",
|
||||
"missingMessage": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"symlinkTitle": "フォルダがシンボリックリンクです",
|
||||
"symlinkMessage": "このフォルダのすべてのコピーがシンボリックリンクです。LoRA Manager の外でリンクまたはそのリンク先を削除してください。",
|
||||
"protectedTitle": "フォルダは保護されています",
|
||||
"protectedMessage": "このフォルダにはレシピライブラリが保存されているため、LoRA Manager から削除できません。",
|
||||
"busyTitle": "保留中の削除があります",
|
||||
"checking": "フォルダの内容を確認しています...",
|
||||
"confirm": "フォルダを削除",
|
||||
"confirmMulti": "{count} 個のフォルダを削除",
|
||||
"deleting": "削除しています...",
|
||||
"keptNote": "チェックを外したコピーがあると、このフォルダはサイドバーに残ります。"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "フォルダ \"{name}\" を削除しました",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "フォルダを復元しました",
|
||||
"failed": "フォルダの削除に失敗しました: {message}",
|
||||
"notEmpty": "このフォルダにはまだモデルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"notEmptyWithCount": "このフォルダにはまだ {count} 個のモデルファイルがあります。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"busy": "このフォルダ内に保留中の削除があります。取り消し可能な時間が過ぎるまでお待ちください。",
|
||||
"unsupported": "このページではフォルダを削除できません",
|
||||
"noRoot": "モデルルートが設定されていません"
|
||||
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。",
|
||||
"symlink": "このフォルダはシンボリックリンクです。サイドバーからは削除できません。",
|
||||
"successMulti": "{count} 個のモデルルートからフォルダ \"{name}\" を削除しました",
|
||||
"partial": "{total} 個のコピーのうち {count} 個を削除しました —— {failed} 個は削除できませんでした。サイドバーを再読み込みしてからもう一度お試しください。"
|
||||
},
|
||||
"renameFolder": "フォルダ名を変更",
|
||||
"renameFolderResult": {
|
||||
@@ -1369,7 +1451,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": "移動先のパスを特定できません。",
|
||||
@@ -1389,6 +1490,10 @@
|
||||
"success": "このフォルダの{type}sに{count}件のアップデートが見つかりました",
|
||||
"none": "このフォルダのすべての{type}sは最新です",
|
||||
"error": "フォルダの{type}アップデート確認に失敗しました: {message}"
|
||||
},
|
||||
"scanFolder": "このフォルダをスキャン",
|
||||
"scanFolderResult": {
|
||||
"missing": "このフォルダはディスク上に存在しません。サイドバーを再読み込みしてからもう一度お試しください。"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "ルートディレクトリを選択",
|
||||
"selectModelRoot": "モデルルートを選択:",
|
||||
"selectTypeRoot": "{type}ルートを選択:",
|
||||
"routingOverride": {
|
||||
"label": "保存先タイプ:",
|
||||
"tooltip": "モデルのメタデータから自動検出されます。誤検出の場合は切り替えてください。切り替えると、このダウンロードでは「デフォルトパスを使用」がオフになります。"
|
||||
},
|
||||
"targetFolderPath": "ターゲットフォルダパス:",
|
||||
"browseFolders": "フォルダを参照:",
|
||||
"createNewFolder": "新しいフォルダを作成",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "元のファイル名を復元しますか?",
|
||||
"revertButton": "元のファイル名を復元"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "サイドカーファイルを集中保存に移動しますか?",
|
||||
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
|
||||
"confirmButton": "今すぐ移動",
|
||||
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
|
||||
"destination": "移動先:{path}",
|
||||
"defaultLocationHint": "デフォルトの保存場所が使用されます。カスタムフォルダを使用するには、キャンセルして上の保存先パスを設定してから、「今すぐサイドカーファイルを移動」をクリックしてください。"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "サイドカーファイル移動の概要",
|
||||
"completedSuccessfully": "移動が正常に完了しました",
|
||||
"completedWithErrors": "{count} 件のエラーで完了しました",
|
||||
"statMoved": "移動したファイル",
|
||||
"statModels": "モデル",
|
||||
"statSkipped": "スキップ",
|
||||
"statConflicts": "解決した競合",
|
||||
"statErrors": "エラー",
|
||||
"failedItems": "失敗した項目({count})",
|
||||
"columnModel": "モデル",
|
||||
"columnError": "エラー",
|
||||
"successMessage": "{models} 個のモデルの {moved} 個のファイルを移動しました",
|
||||
"location": "保存場所:{path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"openSidecarLocation": "メタデータの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信",
|
||||
"copyHash": "ハッシュをコピー",
|
||||
"copyCivitaiId": "Civitai IDをコピー",
|
||||
"civitaiIdCopied": "Civitai IDをクリップボードにコピーしました",
|
||||
"deleteModelWithShortcut": "モデルを削除(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "メタデータの場所を正常に開きました",
|
||||
"failed": "メタデータの場所を開くのに失敗しました",
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI に送信できません:ファイルパスがありません"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "ベースモデル",
|
||||
"size": "サイズ",
|
||||
"hashes": "ハッシュ",
|
||||
"civitaiModelId": "モデル ID",
|
||||
"civitaiVersionId": "バージョン ID",
|
||||
"unknown": "不明",
|
||||
"usageTips": "使用のヒント",
|
||||
"additionalNotes": "追加メモ",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "成人向けコンテンツ",
|
||||
"nsfwR": "R指定コンテンツ",
|
||||
"nsfwX": "X指定コンテンツ",
|
||||
"nsfwXxx": "XXX指定コンテンツ"
|
||||
"nsfwXxx": "XXX指定コンテンツ",
|
||||
"layoutGallery": "ギャラリー表示",
|
||||
"layoutList": "リスト表示"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
@@ -1935,10 +2080,15 @@
|
||||
"earlyAccessTooltip": "このバージョンは現在 CivitAI の早期アクセスが必要です",
|
||||
"paid": "有料",
|
||||
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
|
||||
"priceSaleTooltip": "セール中、通常 {list} Buzz",
|
||||
"priceBlueBuzzTooltip": "Blue Buzz でも支払えます",
|
||||
"freeNow": "無料化",
|
||||
"freeNowTooltip": "このバージョンは支払いが不要になりました",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||
"onSiteOnly": "サイト内のみ",
|
||||
"onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません"
|
||||
"onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません",
|
||||
"earlyAccessTooltipUntil": "{date} まで早期アクセス、以降は無料でダウンロードできます"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "スキップ",
|
||||
"statOffline": "ドライブ オフライン",
|
||||
"statTotal": "スキャン合計",
|
||||
"statDuration": "所要時間",
|
||||
"successMessage": "すべての{count}件の{type}を正常に更新しました",
|
||||
@@ -2677,7 +2828,10 @@
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "アイテムを移動できませんでした: {message}",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"downloadStarted": "ダウンロードを開始しました"
|
||||
"downloadStarted": "ダウンロードを開始しました",
|
||||
"refreshCompleteScoped": "{scope} をスキャンしました:新規 {added} 件、削除 {removed} 件",
|
||||
"refreshKeptUnreachable": "{count} 個のモデルを保持しました:{paths} にアクセスできません",
|
||||
"scanRootUnreachable": "{scope} に現在アクセスできません。変更は行われていません。"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AIプロバイダーが設定されていません。設定 → AIプロバイダーで有効にしてください。",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "ファイル名重複競合"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "集中保存のサイドカーファイル"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI バージョン"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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": "예시 이미지 폴더 정리",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "모델명",
|
||||
"tags": "태그",
|
||||
"creator": "제작자",
|
||||
"hash": "해시",
|
||||
"title": "레시피 제목",
|
||||
"loraName": "LoRA 파일명",
|
||||
"loraModel": "LoRA 모델명",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "LoRA 가용성",
|
||||
"availabilityReady": "바로 사용 가능",
|
||||
"availabilityMissing": "누락된 LoRA 있음",
|
||||
"availabilityDeleted": "삭제된 LoRA 있음"
|
||||
"availabilityDeleted": "삭제된 LoRA 있음",
|
||||
"workflow": "워크플로",
|
||||
"hasWorkflow": "워크플로 있음"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "테마 토글",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
|
||||
},
|
||||
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "알 수 없는 베이스 모델 라우팅",
|
||||
"help": "CivitAI가 보고한 베이스 모델이 알려진 Checkpoint(SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI)도 알려진 Diffusion Model도 아닐 때 Checkpoint 다운로드의 저장 위치를 결정합니다. 새로운 확산 아키텍처는 자주 등장하므로 일반적으로 Diffusion Model로 라우팅하는 것이 올바릅니다.",
|
||||
"options": {
|
||||
"diffusionModel": "Diffusion Model",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "CivitAI 호스트 기본 설정 사용 가능",
|
||||
"content": "이제 CivitAI는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "다운로드 경로 템플릿",
|
||||
"priorityTags": "우선순위 태그",
|
||||
"versionScope": "업데이트 표시",
|
||||
"priceTracking": "Buzz 다운로드 가격",
|
||||
"exampleImages": "예시 이미지",
|
||||
"autoOrganize": "자동 정리",
|
||||
"metadata": "메타데이터",
|
||||
"sidecarStorage": "사이드카 파일 저장",
|
||||
"proxySettings": "프록시 설정"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "호버 시 표시"
|
||||
},
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||
"showcaseLayout": "예시 이미지 레이아웃",
|
||||
"showcaseLayoutHelp": "모델 모달에서 예시 이미지의 표시 방식을 선택하세요: 한 번에 하나씩 보여주는 갤러리 또는 기존 세로 목록.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "갤러리",
|
||||
"vertical": "세로 목록"
|
||||
},
|
||||
"showVersionOnCard": "카드에 버전 표시",
|
||||
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
|
||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||
@@ -745,6 +767,13 @@
|
||||
"label": "유료 업데이트 숨기기",
|
||||
"help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
|
||||
},
|
||||
"priceTracking": {
|
||||
"help": "CivitAI 공개 API는 다운로드 가격을 제공하지 않으므로 모델 페이지에서 읽어옵니다. 유료 모델마다 요청이 한 번씩 추가되며, 아래 간격에 따라 갱신됩니다. 가격은 아직 보유하지 않은 버전에만 표시됩니다.",
|
||||
"enabled": "유료 버전의 다운로드 가격 표시",
|
||||
"enabledHelp": "모델 페이지에서 다운로드 가격을 읽어 유료 및 얼리 액세스 버전의 비용과 얼리 액세스 종료 시점을 표시합니다",
|
||||
"refreshHours": "가격 갱신 간격(시간)",
|
||||
"refreshHoursHelp": "저장된 가격을 다시 읽기 전까지 유지하는 기간"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "다운로드가 성공적으로 완료되었습니다",
|
||||
"enableCivarchiveApi": "CivArchive API를 메타데이터 제공자로 활성화",
|
||||
"enableCivarchiveApiHelp": "활성화하면 CivArchive API가 모델 메타데이터의 대체 소스로 사용됩니다 (예: CivitAI에서 삭제된 모델의 경우). 비활성화하면 CivArchive의 속도 제한을 완전히 피할 수 있습니다.",
|
||||
"enableOpenmodeldbApi": "OpenModelDB를 메타데이터 제공자로 활성화",
|
||||
"enableOpenmodeldbApiHelp": "활성화하면 CivitAI에 기록이 없을 때 OpenModelDB 카탈로그(openmodeldb.info)에서도 Upscaler 메타데이터를 조회합니다. 카탈로그는 로컬에 캐시되며 매일 새로 고쳐집니다.",
|
||||
"providerOrder": "메타데이터 제공자 폴백 순서",
|
||||
"providerOrderHelp": "CivitAI API가 항상 먼저 시도됩니다. 메타데이터 조회 시 나머지 제공자의 순서를 선택하세요.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "사이드카 파일 저장 모드",
|
||||
"modeHelp": ".metadata.json 사이드카 파일과 미리보기 이미지를 저장할 위치를 선택하세요. 각 모델 파일 옆에 저장하거나, 라이브러리 구조를 미러링하는 단일 중앙 집중식 디렉터리에 저장할 수 있습니다. .civitai.info 파일은 항상 모델 파일 옆에 남습니다.",
|
||||
"modeOptions": {
|
||||
"alongside": "모델 파일 옆 (기본값)",
|
||||
"centralized": "중앙 집중식 저장"
|
||||
},
|
||||
"path": "중앙 집중식 저장 경로",
|
||||
"pathHelp": "사이드카 파일을 중앙 집중식으로 저장할 루트 디렉터리입니다. 비워 두면 기본 위치(<settings dir>/sidecars)를 사용합니다.",
|
||||
"pathPlaceholder": "비움 = <settings dir>/sidecars",
|
||||
"management": "사이드카 파일 이동",
|
||||
"managementHelp": "기존 .metadata.json 사이드카 파일과 미리보기 이미지를 현재 선택한 모드에 맞게 '모델 파일 옆'과 '중앙 집중식 저장' 사이에서 이동합니다. 모드를 변경해도 기존 파일은 자동으로 이동되지 않습니다.",
|
||||
"migrateButton": "지금 사이드카 파일 이동",
|
||||
"migratingButton": "이동 중...",
|
||||
"migrating": "사이드카 파일을 이동하는 중...",
|
||||
"migrateFailed": "사이드카 파일 이동 실패: {message}",
|
||||
"migrationDeferred": "기존 사이드카 파일은 이동되지 않았습니다. 나중에 설정 → 라이브러리 → 사이드카 파일 저장에서 이동할 수 있습니다.",
|
||||
"confirmToCentralized": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 중앙 집중식 저장 디렉터리로 이동할까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
|
||||
"confirmToAlongside": "저장 모드가 변경되었지만 기존 .metadata.json 사이드카 파일과 미리보기 이미지는 자동으로 이동되지 않습니다. 지금 각 모델 파일 옆으로 되돌릴까요? '지금 사이드카 파일 이동' 버튼으로 나중에 실행할 수도 있습니다.",
|
||||
"confirmToCentralizedManual": "기존 .metadata.json 사이드카 파일과 미리보기 이미지를 지금 중앙 집중식 저장 디렉터리로 이동할까요?",
|
||||
"confirmToAlongsideManual": "기존 .metadata.json 사이드카 파일과 미리보기 이미지를 지금 각 모델 파일 옆으로 되돌릴까요?",
|
||||
"confirmRelocateRoot": "중앙 집중식 저장 디렉터리가 변경되었지만 기존 사이드카 파일과 미리보기 이미지는 아직 이전 디렉터리에 있습니다. 지금 새 디렉터리로 이동할까요?",
|
||||
"effectivePathLabel": "실제 저장 위치:",
|
||||
"openFolderButton": "폴더 열기",
|
||||
"repoWarning": "실제 저장 위치가 LoRA Manager 설치 폴더 안에 있습니다. 플러그인을 재설치하거나 클린 업데이트하면 삭제될 수 있으니 설치 폴더 밖에 명시적인 저장 경로를 설정하세요.",
|
||||
"openLocationSuccess": "사이드카 파일 저장 폴더를 열었습니다",
|
||||
"openLocationCopied": "사이드카 파일 저장 경로가 클립보드에 복사되었습니다: {path}",
|
||||
"openLocationClipboardFallback": "사이드카 파일 저장 경로를 직접 복사하세요: {path}",
|
||||
"openLocationFailed": "사이드카 파일 저장 폴더를 열지 못했습니다"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "앱 수준 프록시 활성화",
|
||||
"enableProxyHelp": "이 애플리케이션에 대한 사용자 지정 프록시 설정을 활성화하여 시스템 프록시 설정을 무시합니다",
|
||||
@@ -861,7 +922,10 @@
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요.",
|
||||
"scopeSection": "폴더 하나만 스캔",
|
||||
"rootOffline": "오프라인",
|
||||
"rootModels": "모델 {count}개"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "CivitAI에서 메타데이터 가져오기",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
|
||||
"sent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
"sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다",
|
||||
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
|
||||
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다",
|
||||
"hasWorkflow": "ComfyUI 워크플로 포함"
|
||||
},
|
||||
"status": {
|
||||
"ready": "바로 사용 가능",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "이 폴더에는 모델이 없습니다. 폴더 안의 다른 파일도 함께 삭제됩니다.",
|
||||
"notEmptyTitle": "폴더가 비어 있지 않습니다",
|
||||
"notEmptyMessage": "이 폴더에는 아직 모델이 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"confirm": "폴더 삭제"
|
||||
"notEmptyMessageCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 먼저 해당 모델을 삭제하거나 이동하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"notEmptyMessageExcluded": "이 폴더에는 아직 모델 파일이 {count}개 있으며, 그중 {excluded}개는 라이브러리에서 제외되어 있습니다. '제외된 모델 관리'에서 제외를 해제한 뒤 먼저 삭제하세요 —— 폴더를 삭제해도 모델 파일이 함께 삭제되지는 않습니다.",
|
||||
"missingTitle": "폴더를 찾을 수 없습니다",
|
||||
"missingMessage": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"symlinkTitle": "폴더가 심볼릭 링크입니다",
|
||||
"symlinkMessage": "이 폴더의 모든 복사본이 심볼릭 링크입니다. LoRA Manager 외부에서 링크 또는 그 대상을 삭제하세요.",
|
||||
"protectedTitle": "보호된 폴더",
|
||||
"protectedMessage": "이 폴더에는 레시피 라이브러리가 저장되어 있으므로 LoRA Manager에서 삭제할 수 없습니다.",
|
||||
"busyTitle": "대기 중인 삭제 작업이 있습니다",
|
||||
"checking": "폴더 내용을 확인하는 중...",
|
||||
"confirm": "폴더 삭제",
|
||||
"confirmMulti": "폴더 {count}개 삭제",
|
||||
"deleting": "삭제하는 중...",
|
||||
"keptNote": "선택하지 않은 복사본이 있으면 이 폴더가 사이드바에 계속 표시됩니다."
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "\"{name}\" 폴더를 삭제했습니다",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "폴더를 복원했습니다",
|
||||
"failed": "폴더 삭제 실패: {message}",
|
||||
"notEmpty": "이 폴더에는 아직 모델이 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"notEmptyWithCount": "이 폴더에는 아직 모델 파일이 {count}개 있습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"busy": "이 폴더에 아직 대기 중인 삭제 작업이 있습니다. 되돌리기 시간이 끝날 때까지 기다리세요.",
|
||||
"unsupported": "이 페이지에서는 폴더를 삭제할 수 없습니다",
|
||||
"noRoot": "모델 루트가 설정되지 않았습니다"
|
||||
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요.",
|
||||
"symlink": "이 폴더는 심볼릭 링크이므로 사이드바에서 삭제할 수 없습니다.",
|
||||
"successMulti": "모델 루트 {count}개에서 \"{name}\" 폴더를 삭제했습니다",
|
||||
"partial": "복사본 {total}개 중 {count}개를 삭제했습니다 —— {failed}개는 삭제하지 못했습니다. 사이드바를 새로 고친 후 다시 시도하세요."
|
||||
},
|
||||
"renameFolder": "폴더 이름 바꾸기",
|
||||
"renameFolderResult": {
|
||||
@@ -1369,7 +1451,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": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
@@ -1389,6 +1490,10 @@
|
||||
"success": "이 폴더에서 {type}s에 대한 {count}개 업데이트를 찾았습니다",
|
||||
"none": "이 폴더의 모든 {type}s가 최신 상태입니다",
|
||||
"error": "폴더의 {type} 업데이트 확인 실패: {message}"
|
||||
},
|
||||
"scanFolder": "이 폴더 스캔",
|
||||
"scanFolderResult": {
|
||||
"missing": "이 폴더는 디스크에 더 이상 존재하지 않습니다. 사이드바를 새로 고친 후 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "루트 디렉토리를 선택하세요",
|
||||
"selectModelRoot": "모델 루트 선택:",
|
||||
"selectTypeRoot": "{type} 루트 선택:",
|
||||
"routingOverride": {
|
||||
"label": "대상 유형:",
|
||||
"tooltip": "모델 메타데이터에서 자동으로 감지됩니다. 잘못 감지된 경우 전환하세요. 전환하면 이 다운로드에서 '기본 경로 사용'이 꺼집니다."
|
||||
},
|
||||
"targetFolderPath": "대상 폴더 경로:",
|
||||
"browseFolders": "폴더 탐색:",
|
||||
"createNewFolder": "새 폴더 만들기",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "원본 파일명을 복원하시겠습니까?",
|
||||
"revertButton": "원본 파일명 복원"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "사이드카 파일을 중앙 집중식 저장으로 이동할까요?",
|
||||
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
|
||||
"confirmButton": "지금 이동",
|
||||
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
|
||||
"destination": "대상 위치: {path}",
|
||||
"defaultLocationHint": "기본 저장 위치가 사용됩니다. 사용자 지정 폴더를 사용하려면 취소하고 위에서 저장 경로를 설정한 다음 '지금 사이드카 파일 이동'을 클릭하세요."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "사이드카 파일 이동 요약",
|
||||
"completedSuccessfully": "이동이 성공적으로 완료되었습니다",
|
||||
"completedWithErrors": "{count}개의 오류와 함께 완료되었습니다",
|
||||
"statMoved": "이동된 파일",
|
||||
"statModels": "모델",
|
||||
"statSkipped": "건너뜀",
|
||||
"statConflicts": "해결된 충돌",
|
||||
"statErrors": "오류",
|
||||
"failedItems": "실패한 항목 ({count})",
|
||||
"columnModel": "모델",
|
||||
"columnError": "오류",
|
||||
"successMessage": "{models}개 모델의 파일 {moved}개를 이동했습니다",
|
||||
"location": "저장 위치: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"openSidecarLocation": "메타데이터 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||
"copyHash": "해시 복사",
|
||||
"copyCivitaiId": "Civitai ID 복사",
|
||||
"civitaiIdCopied": "Civitai ID가 클립보드에 복사되었습니다",
|
||||
"deleteModelWithShortcut": "모델 삭제(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "메타데이터 위치가 성공적으로 열렸습니다",
|
||||
"failed": "메타데이터 위치 열기에 실패했습니다",
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "ComfyUI로 보낼 수 없습니다: 파일 경로가 없습니다"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "베이스 모델",
|
||||
"size": "크기",
|
||||
"hashes": "해시",
|
||||
"civitaiModelId": "모델 ID",
|
||||
"civitaiVersionId": "버전 ID",
|
||||
"unknown": "알 수 없음",
|
||||
"usageTips": "사용 팁",
|
||||
"additionalNotes": "추가 메모",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "성인 콘텐츠",
|
||||
"nsfwR": "R등급 콘텐츠",
|
||||
"nsfwX": "X등급 콘텐츠",
|
||||
"nsfwXxx": "XXX등급 콘텐츠"
|
||||
"nsfwXxx": "XXX등급 콘텐츠",
|
||||
"layoutGallery": "갤러리 보기",
|
||||
"layoutList": "목록 보기"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
@@ -1935,10 +2080,15 @@
|
||||
"earlyAccessTooltip": "이 버전은 현재 CivitAI 얼리 액세스가 필요합니다",
|
||||
"paid": "유료",
|
||||
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
|
||||
"priceSaleTooltip": "할인 중, 원래 {list} Buzz",
|
||||
"priceBlueBuzzTooltip": "Blue Buzz로도 결제할 수 있습니다",
|
||||
"freeNow": "지금 무료",
|
||||
"freeNowTooltip": "이 버전은 더 이상 결제가 필요하지 않습니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||
"onSiteOnly": "사이트 내 전용",
|
||||
"onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||
"onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||
"earlyAccessTooltipUntil": "{date}까지 얼리 액세스, 이후 무료로 다운로드할 수 있습니다"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "성공",
|
||||
"statFailed": "실패",
|
||||
"statSkipped": "건너뜀",
|
||||
"statOffline": "드라이브 오프라인",
|
||||
"statTotal": "총 스캔",
|
||||
"statDuration": "소요 시간",
|
||||
"successMessage": "모든 {count}개 {type}이(가) 성공적으로 업데이트되었습니다",
|
||||
@@ -2677,7 +2828,10 @@
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "항목을 이동하지 못했습니다: {message}",
|
||||
"copiedToClipboard": "클립보드에 복사됨",
|
||||
"downloadStarted": "다운로드 시작됨"
|
||||
"downloadStarted": "다운로드 시작됨",
|
||||
"refreshCompleteScoped": "{scope} 스캔 완료: 새 모델 {added}개, 제거 {removed}개",
|
||||
"refreshKeptUnreachable": "모델 {count}개 유지됨: {paths}에 접근할 수 없음",
|
||||
"scanRootUnreachable": "{scope}에 현재 접근할 수 없습니다. 변경된 내용은 없습니다."
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 제공자가 설정되지 않았습니다. 설정 → AI 제공자에서 활성화하세요.",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "파일명 중복 충돌"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "중앙 집중식 사이드카 파일"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 버전"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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": "Очистить папки с примерами изображений",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "Название модели",
|
||||
"tags": "Теги",
|
||||
"creator": "Автор",
|
||||
"hash": "Хэш",
|
||||
"title": "Название рецепта",
|
||||
"loraName": "Имя файла LoRA",
|
||||
"loraModel": "Название модели LoRA",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "Доступность LoRAs",
|
||||
"availabilityReady": "Готовы к использованию",
|
||||
"availabilityMissing": "Есть отсутствующие",
|
||||
"availabilityDeleted": "Есть удалённые"
|
||||
"availabilityDeleted": "Есть удалённые",
|
||||
"workflow": "Workflow",
|
||||
"hasWorkflow": "С Workflow"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Переключить тему",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
|
||||
},
|
||||
"aria2HelpLink": "Узнайте, как настроить сервер загрузки aria2",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "Маршрутизация неизвестных базовых моделей",
|
||||
"help": "Определяет, куда отправляются загрузки Checkpoint, когда CivitAI сообщает базовую модель, которая не является ни известным Checkpoint (SD 1.x/2.x/3.x, SDXL, Pony, Illustrious, NoobAI), ни известной диффузионной моделью. Новые диффузионные архитектуры появляются часто, поэтому маршрутизация их в диффузионные модели обычно правильная.",
|
||||
"options": {
|
||||
"diffusionModel": "Диффузионные модели",
|
||||
"checkpoint": "Checkpoints"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "Доступна настройка хоста CivitAI",
|
||||
"content": "Теперь CivitAI использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "Шаблоны путей загрузки",
|
||||
"priorityTags": "Приоритетные теги",
|
||||
"versionScope": "Метки обновлений",
|
||||
"priceTracking": "Цены за скачивание в Buzz",
|
||||
"exampleImages": "Примеры изображений",
|
||||
"autoOrganize": "Автоорганизация",
|
||||
"metadata": "Метаданные",
|
||||
"sidecarStorage": "Хранилище sidecar-файлов",
|
||||
"proxySettings": "Настройки прокси"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "Показать при наведении"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||
"showcaseLayout": "Макет примеров изображений",
|
||||
"showcaseLayoutHelp": "Выберите, как отображаются примеры изображений в окне модели: галерея с просмотром по одному изображению или классический вертикальный список.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Галерея",
|
||||
"vertical": "Вертикальный список"
|
||||
},
|
||||
"showVersionOnCard": "Показывать версию на карточке",
|
||||
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
|
||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||
@@ -745,6 +767,13 @@
|
||||
"label": "Скрывать платные обновления",
|
||||
"help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»"
|
||||
},
|
||||
"priceTracking": {
|
||||
"help": "Публичный API CivitAI не публикует цены за скачивание, поэтому они читаются со страницы модели — один дополнительный запрос на каждую платную модель, обновление с интервалом ниже. Цены показываются только для версий, которых у вас ещё нет.",
|
||||
"enabled": "Показывать цены за скачивание платных версий",
|
||||
"enabledHelp": "Читает цену за скачивание со страницы модели, чтобы платные версии и версии с ранним доступом показывали стоимость и дату окончания раннего доступа",
|
||||
"refreshHours": "Интервал обновления цен (часы)",
|
||||
"refreshHoursHelp": "Сколько хранится цена, прежде чем её прочитают снова"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "Загрузка успешно завершена",
|
||||
"enableCivarchiveApi": "Включить CivArchive API как источник метаданных",
|
||||
"enableCivarchiveApiHelp": "При включении CivArchive API используется как резервный источник метаданных моделей (например, для моделей, удалённых с CivitAI). Отключите, чтобы полностью избежать ограничений скорости CivArchive.",
|
||||
"enableOpenmodeldbApi": "Включить OpenModelDB как источник метаданных",
|
||||
"enableOpenmodeldbApiHelp": "При включении метаданные Upscaler также ищутся в каталоге OpenModelDB (openmodeldb.info), если у CivitAI нет записи. Каталог кэшируется локально и обновляется ежедневно.",
|
||||
"providerOrder": "Порядок резервных источников метаданных",
|
||||
"providerOrderHelp": "CivitAI API всегда проверяется первым. Выберите порядок остальных источников при поиске метаданных.",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "Режим хранения sidecar-файлов",
|
||||
"modeHelp": "Выберите, где хранить sidecar-файлы .metadata.json и изображения превью: рядом с каждым файлом модели или в одном централизованном каталоге, повторяющем структуру вашей библиотеки. Файлы .civitai.info всегда остаются рядом с файлом модели.",
|
||||
"modeOptions": {
|
||||
"alongside": "Рядом с файлами моделей (по умолчанию)",
|
||||
"centralized": "Централизованное хранилище"
|
||||
},
|
||||
"path": "Путь централизованного хранилища",
|
||||
"pathHelp": "Корневой каталог централизованного хранилища sidecar-файлов. Оставьте пустым, чтобы использовать расположение по умолчанию (<settings dir>/sidecars).",
|
||||
"pathPlaceholder": "Пусто = <settings dir>/sidecars",
|
||||
"management": "Перенос sidecar-файлов",
|
||||
"managementHelp": "Переносите существующие sidecar-файлы .metadata.json и изображения превью между хранением рядом с моделями и централизованным хранилищем в соответствии с выбранным режимом. Смена режима не переносит существующие файлы автоматически.",
|
||||
"migrateButton": "Перенести sidecar-файлы сейчас",
|
||||
"migratingButton": "Перенос...",
|
||||
"migrating": "Перенос sidecar-файлов...",
|
||||
"migrateFailed": "Не удалось перенести sidecar-файлы: {message}",
|
||||
"migrationDeferred": "Существующие sidecar-файлы не были перенесены. Вы можете перенести их позже в разделе «Настройки → Библиотека → Хранилище sidecar-файлов».",
|
||||
"confirmToCentralized": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Перенести их сейчас в централизованное хранилище? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
|
||||
"confirmToAlongside": "Режим хранения изменён, но существующие sidecar-файлы .metadata.json и изображения превью не переносятся автоматически. Вернуть их сейчас рядом с их файлами моделей? Это также можно сделать позже кнопкой «Перенести sidecar-файлы сейчас».",
|
||||
"confirmToCentralizedManual": "Перенести существующие sidecar-файлы .metadata.json и изображения превью в централизованное хранилище сейчас?",
|
||||
"confirmToAlongsideManual": "Вернуть существующие sidecar-файлы .metadata.json и изображения превью рядом с их файлами моделей сейчас?",
|
||||
"confirmRelocateRoot": "Каталог централизованного хранилища изменён, но существующие sidecar-файлы и изображения превью всё ещё находятся в прежнем каталоге. Перенести их сейчас в новый каталог?",
|
||||
"effectivePathLabel": "Фактическое расположение хранилища:",
|
||||
"openFolderButton": "Открыть папку",
|
||||
"repoWarning": "Фактическое расположение хранилища находится внутри папки установки LoRA Manager. Переустановка плагина или чистое обновление может удалить его — задайте явный путь хранения вне папки установки.",
|
||||
"openLocationSuccess": "Папка хранилища sidecar-файлов открыта",
|
||||
"openLocationCopied": "Путь к хранилищу sidecar-файлов скопирован в буфер обмена: {path}",
|
||||
"openLocationClipboardFallback": "Скопируйте путь к хранилищу sidecar-файлов вручную: {path}",
|
||||
"openLocationFailed": "Не удалось открыть папку хранилища sidecar-файлов"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "Включить прокси на уровне приложения",
|
||||
"enableProxyHelp": "Включить пользовательские настройки прокси для этого приложения, переопределяя системные настройки прокси",
|
||||
@@ -861,7 +922,10 @@
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок.",
|
||||
"scopeSection": "Сканировать только одну папку",
|
||||
"rootOffline": "Недоступен",
|
||||
"rootModels": "{count} модел(ей)"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Получить метаданные с CivitAI",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "Отправить workflow в ComfyUI",
|
||||
"sent": "Workflow отправлен в ComfyUI",
|
||||
"sendFailed": "Не удалось отправить workflow в ComfyUI",
|
||||
"noWorkflow": "В этом рецепте не найден встроенный workflow"
|
||||
"noWorkflow": "В этом рецепте не найден встроенный workflow",
|
||||
"hasWorkflow": "Содержит Workflow ComfyUI"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Готово к использованию",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "В этой папке нет моделей. Остальные файлы в ней тоже будут удалены.",
|
||||
"notEmptyTitle": "Папка не пуста",
|
||||
"notEmptyMessage": "В этой папке ещё есть модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"confirm": "Удалить папку"
|
||||
"notEmptyMessageCount": "В этой папке ещё есть {count} файл(ов) модели. Сначала удалите или переместите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"notEmptyMessageExcluded": "В этой папке ещё есть {count} файл(ов) модели, из них {excluded} исключены из библиотеки. Снимите исключение в разделе «Управление исключёнными моделями» и удалите их — удаление папки никогда не затрагивает файлы моделей.",
|
||||
"missingTitle": "Папка не найдена",
|
||||
"missingMessage": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
|
||||
"symlinkTitle": "Папка является символической ссылкой",
|
||||
"symlinkMessage": "Все копии этой папки являются символическими ссылками; удалите ссылку или её цель вне LoRA Manager.",
|
||||
"protectedTitle": "Папка защищена",
|
||||
"protectedMessage": "В этой папке хранится библиотека рецептов, её нельзя удалить из LoRA Manager.",
|
||||
"busyTitle": "Удаление всё ещё отложено",
|
||||
"checking": "Проверка содержимого папки...",
|
||||
"confirm": "Удалить папку",
|
||||
"confirmMulti": "Удалить папки ({count})",
|
||||
"deleting": "Удаление...",
|
||||
"keptNote": "Неотмеченные копии оставят эту папку в боковой панели."
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "Папка \"{name}\" удалена",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "Папка восстановлена",
|
||||
"failed": "Не удалось удалить папку: {message}",
|
||||
"notEmpty": "В этой папке ещё есть модели. Обновите боковую панель и повторите попытку.",
|
||||
"notEmptyWithCount": "В этой папке ещё есть {count} файл(ов) модели. Обновите боковую панель и повторите попытку.",
|
||||
"busy": "В этой папке всё ещё есть отложенное удаление. Дождитесь окончания окна отмены.",
|
||||
"unsupported": "Удаление папок не поддерживается на этой странице",
|
||||
"noRoot": "Корневая папка моделей не настроена"
|
||||
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку.",
|
||||
"symlink": "Эта папка является символической ссылкой; её нельзя удалить из боковой панели.",
|
||||
"successMulti": "Папка \"{name}\" удалена из {count} корневых папок моделей",
|
||||
"partial": "Удалено {count} из {total} копий — не удалось удалить {failed}. Обновите боковую панель и повторите попытку."
|
||||
},
|
||||
"renameFolder": "Переименовать папку",
|
||||
"renameFolderResult": {
|
||||
@@ -1369,7 +1451,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": "Не удалось определить путь назначения для перемещения.",
|
||||
@@ -1389,6 +1490,10 @@
|
||||
"success": "Найдено {count} обновление(й) для {type}s в этой папке",
|
||||
"none": "Все {type}s в этой папке актуальны",
|
||||
"error": "Не удалось проверить папку на наличие обновлений {type}: {message}"
|
||||
},
|
||||
"scanFolder": "Сканировать эту папку",
|
||||
"scanFolderResult": {
|
||||
"missing": "Эта папка больше не существует на диске. Обновите боковую панель и повторите попытку."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "Выберите корневую папку",
|
||||
"selectModelRoot": "Выберите корень моделей:",
|
||||
"selectTypeRoot": "Выберите корень {type}:",
|
||||
"routingOverride": {
|
||||
"label": "Тип назначения:",
|
||||
"tooltip": "Определяется автоматически из метаданных модели. Переключите, если определение неверно; переключение отключает «Использовать путь по умолчанию» для этой загрузки."
|
||||
},
|
||||
"targetFolderPath": "Путь к целевой папке:",
|
||||
"browseFolders": "Обзор папок:",
|
||||
"createNewFolder": "Создать новую папку",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "Восстановить исходные имена файлов?",
|
||||
"revertButton": "Восстановить исходные имена файлов"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "Перенести sidecar-файлы в централизованное хранилище?",
|
||||
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
|
||||
"confirmButton": "Перенести сейчас",
|
||||
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
|
||||
"destination": "Назначение: {path}",
|
||||
"defaultLocationHint": "Будет использовано расположение по умолчанию. Чтобы использовать свою папку, отмените, укажите путь хранилища выше, затем нажмите «Перенести sidecar-файлы сейчас»."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Сводка переноса sidecar-файлов",
|
||||
"completedSuccessfully": "Перенос успешно завершён",
|
||||
"completedWithErrors": "Завершено с ошибками ({count})",
|
||||
"statMoved": "Перенесено файлов",
|
||||
"statModels": "Модели",
|
||||
"statSkipped": "Пропущено",
|
||||
"statConflicts": "Разрешено конфликтов",
|
||||
"statErrors": "Ошибки",
|
||||
"failedItems": "Элементы с ошибками ({count})",
|
||||
"columnModel": "Модель",
|
||||
"columnError": "Ошибка",
|
||||
"successMessage": "Перенесено {moved} файлов для {models} моделей",
|
||||
"location": "Расположение хранилища: {path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "Открыть Hugging Face",
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"openSidecarLocation": "Открыть расположение метаданных",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
"sendToWorkflowText": "Отправить в ComfyUI",
|
||||
"copyHash": "Копировать хеш",
|
||||
"copyCivitaiId": "Копировать ID Civitai",
|
||||
"civitaiIdCopied": "ID Civitai скопирован в буфер обмена",
|
||||
"deleteModelWithShortcut": "Удалить модель (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "Расположение метаданных успешно открыто",
|
||||
"failed": "Не удалось открыть расположение метаданных",
|
||||
"copied": "Путь скопирован в буфер обмена: {{path}}",
|
||||
"clipboardFallback": "Путь: {{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "Невозможно отправить в ComfyUI: путь к файлу недоступен"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "Базовая модель",
|
||||
"size": "Размер",
|
||||
"hashes": "Хэши",
|
||||
"civitaiModelId": "ID модели",
|
||||
"civitaiVersionId": "ID версии",
|
||||
"unknown": "Неизвестно",
|
||||
"usageTips": "Советы по использованию",
|
||||
"additionalNotes": "Дополнительные заметки",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "Контент для взрослых",
|
||||
"nsfwR": "Контент с рейтингом R",
|
||||
"nsfwX": "Контент с рейтингом X",
|
||||
"nsfwXxx": "Контент с рейтингом XXX"
|
||||
"nsfwXxx": "Контент с рейтингом XXX",
|
||||
"layoutGallery": "Галерея",
|
||||
"layoutList": "Список"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
@@ -1935,10 +2080,15 @@
|
||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ CivitAI",
|
||||
"paid": "Платная",
|
||||
"paidTooltip": "Скачивание этой версии платное",
|
||||
"priceSaleTooltip": "Со скидкой, обычно {list} Buzz",
|
||||
"priceBlueBuzzTooltip": "Можно также оплатить Blue Buzz",
|
||||
"freeNow": "Теперь бесплатно",
|
||||
"freeNowTooltip": "Эта версия больше не требует оплаты",
|
||||
"ignored": "Игнорируется",
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||
"onSiteOnly": "Только на Сайте",
|
||||
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI"
|
||||
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте CivitAI",
|
||||
"earlyAccessTooltipUntil": "Ранний доступ до {date}, после этого скачивание бесплатно"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "Успешно",
|
||||
"statFailed": "Ошибка",
|
||||
"statSkipped": "Пропущено",
|
||||
"statOffline": "Диск недоступен",
|
||||
"statTotal": "Всего проверено",
|
||||
"statDuration": "Длительность",
|
||||
"successMessage": "Все {count} {type}s успешно обновлены",
|
||||
@@ -2677,7 +2828,10 @@
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||
"moveFailed": "Не удалось переместить элемент: {message}",
|
||||
"copiedToClipboard": "Скопировано в буфер обмена",
|
||||
"downloadStarted": "Загрузка начата"
|
||||
"downloadStarted": "Загрузка начата",
|
||||
"refreshCompleteScoped": "{scope}: просканировано — новых {added}, удалено {removed}",
|
||||
"refreshKeptUnreachable": "Сохранено моделей: {count} — {paths} недоступны",
|
||||
"scanRootUnreachable": "{scope} сейчас недоступен. Изменений не внесено."
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "Поставщик ИИ не настроен. Включите его в Настройки → Поставщик ИИ.",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "Конфликты дублирующихся имён файлов"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "Централизованные sidecar-файлы"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "Версия интерфейса"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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": "清理示例图片文件夹",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "模型名称",
|
||||
"tags": "标签",
|
||||
"creator": "创作者",
|
||||
"hash": "哈希",
|
||||
"title": "配方标题",
|
||||
"loraName": "LoRA 文件名",
|
||||
"loraModel": "LoRA 模型名称",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺失 LoRA",
|
||||
"availabilityDeleted": "包含已删除 LoRA"
|
||||
"availabilityDeleted": "包含已删除 LoRA",
|
||||
"workflow": "工作流",
|
||||
"hasWorkflow": "包含工作流"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切换主题",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "留空则使用 PATH 中的 aria2c"
|
||||
},
|
||||
"aria2HelpLink": "了解如何配置 aria2 下载后端",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "未知基础模型路由",
|
||||
"help": "当 CivitAI 报告的基础模型既不是已知的 Checkpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI),也不是已知的扩散模型时,决定 Checkpoint 下载的保存位置。新的扩散架构频繁出现,因此将其路由到扩散模型通常是正确的。",
|
||||
"options": {
|
||||
"diffusionModel": "扩散模型",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 CivitAI 站点偏好设置",
|
||||
"content": "CivitAI 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "下载路径模板",
|
||||
"priorityTags": "优先标签",
|
||||
"versionScope": "版本范围",
|
||||
"priceTracking": "Buzz 下载价格",
|
||||
"exampleImages": "示例图片",
|
||||
"autoOrganize": "自动整理",
|
||||
"metadata": "元数据",
|
||||
"sidecarStorage": "附属文件存储",
|
||||
"proxySettings": "代理设置"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "悬停时显示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||
"showcaseLayout": "示例图片布局",
|
||||
"showcaseLayoutHelp": "选择示例图片在模型弹窗中的显示方式:一次显示一张的画廊,或经典的纵向列表。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "画廊",
|
||||
"vertical": "纵向列表"
|
||||
},
|
||||
"showVersionOnCard": "在卡片上显示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
|
||||
"modelCardFooterAction": "模型卡片按钮操作",
|
||||
@@ -745,6 +767,13 @@
|
||||
"label": "隐藏付费更新",
|
||||
"help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
|
||||
},
|
||||
"priceTracking": {
|
||||
"help": "CivitAI 的公开 API 不提供下载价格,因此价格从模型页面读取 —— 每个付费模型多一次请求,按下面的间隔刷新。价格只显示在你尚未拥有的版本上。",
|
||||
"enabled": "显示付费版本的下载价格",
|
||||
"enabledHelp": "从模型页面读取下载价格,让付费与抢先体验版本显示所需费用,以及抢先体验的结束时间",
|
||||
"refreshHours": "价格刷新间隔(小时)",
|
||||
"refreshHoursHelp": "已保存的价格在重新读取前保持有效的时长"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版许可协议图标",
|
||||
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "下载成功完成",
|
||||
"enableCivarchiveApi": "启用 CivArchive API 作为元数据提供者",
|
||||
"enableCivarchiveApiHelp": "开启后,CivArchive API 将作为模型元数据的备用来源(例如用于已从 CivitAI 删除的模型)。关闭可完全避免 CivArchive 的速率限制。",
|
||||
"enableOpenmodeldbApi": "启用 OpenModelDB 作为元数据提供者",
|
||||
"enableOpenmodeldbApiHelp": "开启后,当 CivitAI 没有记录时,也会在 OpenModelDB 目录(openmodeldb.info)中查询 Upscaler 元数据。目录会缓存在本地并每天刷新。",
|
||||
"providerOrder": "元数据提供者回退顺序",
|
||||
"providerOrderHelp": "CivitAI API 始终优先尝试。选择查找元数据时其余提供者的顺序。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "附属文件存储模式",
|
||||
"modeHelp": "选择 .metadata.json 附属文件和预览图片的存放位置:与每个模型文件放在一起,或集中存放在一个镜像模型库结构的目录中。.civitai.info 文件始终与模型文件放在一起。",
|
||||
"modeOptions": {
|
||||
"alongside": "与模型文件放在一起(默认)",
|
||||
"centralized": "集中存储"
|
||||
},
|
||||
"path": "集中存储路径",
|
||||
"pathHelp": "集中存储附属文件的根目录。留空则使用默认位置(<settings dir>/sidecars)。",
|
||||
"pathPlaceholder": "留空 = <settings dir>/sidecars",
|
||||
"management": "附属文件迁移",
|
||||
"managementHelp": "在“与模型文件放在一起”和“集中存储”之间迁移现有的 .metadata.json 附属文件和预览图片,以匹配当前选择的模式。更改模式不会自动迁移现有文件。",
|
||||
"migrateButton": "立即迁移附属文件",
|
||||
"migratingButton": "迁移中...",
|
||||
"migrating": "正在迁移附属文件...",
|
||||
"migrateFailed": "附属文件迁移失败:{message}",
|
||||
"migrationDeferred": "现有附属文件未迁移。你可以稍后在“设置 → 库 → 附属文件存储”中迁移它们。",
|
||||
"confirmToCentralized": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移入集中存储目录吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
|
||||
"confirmToAlongside": "存储模式已更改,但现有的 .metadata.json 附属文件和预览图片不会自动迁移。要现在将它们移回各自的模型文件旁边吗?你也可以稍后使用“立即迁移附属文件”按钮完成。",
|
||||
"confirmToCentralizedManual": "要现在将现有的 .metadata.json 附属文件和预览图片移入集中存储目录吗?",
|
||||
"confirmToAlongsideManual": "要现在将现有的 .metadata.json 附属文件和预览图片移回各自的模型文件旁边吗?",
|
||||
"confirmRelocateRoot": "集中存储目录已更改,但现有的附属文件和预览图片仍在原目录中。要现在将它们移到新目录吗?",
|
||||
"effectivePathLabel": "实际存储位置:",
|
||||
"openFolderButton": "打开文件夹",
|
||||
"repoWarning": "实际存储位置位于 LoRA Manager 安装目录内。重新安装插件或干净更新可能会将其删除——请在安装目录之外设置一个明确的存储路径。",
|
||||
"openLocationSuccess": "已打开附属文件存储文件夹",
|
||||
"openLocationCopied": "附属文件存储路径已复制到剪贴板:{path}",
|
||||
"openLocationClipboardFallback": "请手动复制附属文件存储路径:{path}",
|
||||
"openLocationFailed": "打开附属文件存储文件夹失败"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "启用应用级代理",
|
||||
"enableProxyHelp": "为此应用启用自定义代理设置,覆盖系统代理设置",
|
||||
@@ -861,7 +922,10 @@
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。",
|
||||
"scopeSection": "只扫描一个文件夹",
|
||||
"rootOffline": "离线",
|
||||
"rootModels": "{count} 个模型"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "从 CivitAI 获取元数据",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "发送工作流到 ComfyUI",
|
||||
"sent": "工作流已发送到 ComfyUI",
|
||||
"sendFailed": "发送工作流到 ComfyUI 失败",
|
||||
"noWorkflow": "此配方中未找到内嵌工作流"
|
||||
"noWorkflow": "此配方中未找到内嵌工作流",
|
||||
"hasWorkflow": "包含 ComfyUI 工作流"
|
||||
},
|
||||
"status": {
|
||||
"ready": "可直接使用",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "该文件夹中没有模型,其中的其他文件也会一并删除。",
|
||||
"notEmptyTitle": "文件夹不为空",
|
||||
"notEmptyMessage": "该文件夹中仍有模型,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"confirm": "删除文件夹"
|
||||
"notEmptyMessageCount": "该文件夹中仍有 {count} 个模型文件,请先删除或移出这些模型 —— 删除文件夹不会级联删除模型文件。",
|
||||
"notEmptyMessageExcluded": "该文件夹中仍有 {count} 个模型文件,其中 {excluded} 个已从模型库中排除。请先在“管理已排除的模型”中取消排除并删除它们 —— 删除文件夹不会级联删除模型文件。",
|
||||
"missingTitle": "未找到文件夹",
|
||||
"missingMessage": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
|
||||
"symlinkTitle": "文件夹是符号链接",
|
||||
"symlinkMessage": "该文件夹的每一份副本都是符号链接;请在 LoRA Manager 之外删除该链接或其指向的目标。",
|
||||
"protectedTitle": "文件夹受保护",
|
||||
"protectedMessage": "此文件夹存放着配方库,无法在 LoRA Manager 中删除。",
|
||||
"busyTitle": "仍有删除操作待处理",
|
||||
"checking": "正在检查文件夹内容...",
|
||||
"confirm": "删除文件夹",
|
||||
"confirmMulti": "删除 {count} 个文件夹",
|
||||
"deleting": "正在删除...",
|
||||
"keptNote": "未勾选的副本会让该文件夹继续显示在侧边栏中。"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "已删除文件夹 \"{name}\"",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "文件夹已恢复",
|
||||
"failed": "删除文件夹失败: {message}",
|
||||
"notEmpty": "该文件夹中仍有模型。请刷新侧边栏后重试。",
|
||||
"notEmptyWithCount": "该文件夹中仍有 {count} 个模型文件。请刷新侧边栏后重试。",
|
||||
"busy": "该文件夹内仍有待处理的删除操作,请等待撤销窗口结束。",
|
||||
"unsupported": "此页面不支持删除文件夹",
|
||||
"noRoot": "未配置模型根目录"
|
||||
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。",
|
||||
"symlink": "该文件夹是符号链接,无法从侧边栏中删除。",
|
||||
"successMulti": "已从 {count} 个模型根目录中删除文件夹 \"{name}\"",
|
||||
"partial": "已删除 {total} 份副本中的 {count} 份 —— {failed} 份无法删除。请刷新侧边栏后重试。"
|
||||
},
|
||||
"renameFolder": "重命名文件夹",
|
||||
"renameFolderResult": {
|
||||
@@ -1369,7 +1451,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": "无法确定移动的目标路径。",
|
||||
@@ -1389,6 +1490,10 @@
|
||||
"success": "在此文件夹中找到 {count} 个{type}更新",
|
||||
"none": "此文件夹中的所有{type}都是最新版本",
|
||||
"error": "检查文件夹{type}更新失败: {message}"
|
||||
},
|
||||
"scanFolder": "扫描此文件夹",
|
||||
"scanFolderResult": {
|
||||
"missing": "该文件夹在磁盘上已不存在,请刷新侧边栏后重试。"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "选择根目录",
|
||||
"selectModelRoot": "选择模型根目录:",
|
||||
"selectTypeRoot": "选择 {type} 根目录:",
|
||||
"routingOverride": {
|
||||
"label": "目标类型:",
|
||||
"tooltip": "根据模型元数据自动检测。如果检测结果有误,请切换;切换会为本次下载关闭“使用默认路径”。"
|
||||
},
|
||||
"targetFolderPath": "目标文件夹路径:",
|
||||
"browseFolders": "浏览文件夹:",
|
||||
"createNewFolder": "新建文件夹",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "恢复原始文件名?",
|
||||
"revertButton": "恢复原始文件名"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "要将附属文件移到集中存储吗?",
|
||||
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
|
||||
"confirmButton": "立即迁移",
|
||||
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
|
||||
"destination": "目标位置:{path}",
|
||||
"defaultLocationHint": "将使用默认存储位置。如需使用自定义文件夹,请取消,在上方设置存储路径,然后点击“立即迁移附属文件”。"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "附属文件迁移摘要",
|
||||
"completedSuccessfully": "迁移已成功完成",
|
||||
"completedWithErrors": "完成,但有 {count} 个错误",
|
||||
"statMoved": "已迁移文件",
|
||||
"statModels": "模型",
|
||||
"statSkipped": "已跳过",
|
||||
"statConflicts": "已解决冲突",
|
||||
"statErrors": "错误",
|
||||
"failedItems": "失败项({count})",
|
||||
"columnModel": "模型",
|
||||
"columnError": "错误",
|
||||
"successMessage": "已为 {models} 个模型迁移 {moved} 个文件",
|
||||
"location": "存储位置:{path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"openSidecarLocation": "打开元数据位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI",
|
||||
"copyHash": "复制哈希值",
|
||||
"copyCivitaiId": "复制 Civitai ID",
|
||||
"civitaiIdCopied": "Civitai ID 已复制到剪贴板",
|
||||
"deleteModelWithShortcut": "删除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "元数据位置已成功打开",
|
||||
"failed": "打开元数据位置失败",
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "无法发送到 ComfyUI:没有可用的文件路径"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "基础模型",
|
||||
"size": "大小",
|
||||
"hashes": "哈希值",
|
||||
"civitaiModelId": "模型 ID",
|
||||
"civitaiVersionId": "版本 ID",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加备注",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "成熟内容",
|
||||
"nsfwR": "R 级内容",
|
||||
"nsfwX": "X 级内容",
|
||||
"nsfwXxx": "XXX 级内容"
|
||||
"nsfwXxx": "XXX 级内容",
|
||||
"layoutGallery": "画廊视图",
|
||||
"layoutList": "列表视图"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
@@ -1935,10 +2080,15 @@
|
||||
"earlyAccessTooltip": "此版本当前需要 CivitAI 抢先体验权限",
|
||||
"paid": "付费",
|
||||
"paidTooltip": "此版本需要付费后才能下载",
|
||||
"priceSaleTooltip": "促销中,原价 {list} Buzz",
|
||||
"priceBlueBuzzTooltip": "也可以使用 Blue Buzz 支付",
|
||||
"freeNow": "现已免费",
|
||||
"freeNowTooltip": "此版本已不再需要付费",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知",
|
||||
"onSiteOnly": "仅站内生成",
|
||||
"onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载"
|
||||
"onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载",
|
||||
"earlyAccessTooltipUntil": "抢先体验至 {date},之后可免费下载"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失败",
|
||||
"statSkipped": "已跳过",
|
||||
"statOffline": "驱动器离线",
|
||||
"statTotal": "总计扫描",
|
||||
"statDuration": "耗时",
|
||||
"successMessage": "全部 {count} 个 {type} 更新成功!",
|
||||
@@ -2677,7 +2828,10 @@
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "移动条目失败:{message}",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"downloadStarted": "下载已开始"
|
||||
"downloadStarted": "下载已开始",
|
||||
"refreshCompleteScoped": "已扫描 {scope}:新增 {added},移除 {removed}",
|
||||
"refreshKeptUnreachable": "已保留 {count} 个模型:{paths} 当前不可访问",
|
||||
"scanRootUnreachable": "{scope} 当前不可访问,未做任何改动。"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 提供商未配置。请在 设置 → AI 提供商 中进行配置。",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "文件名重复冲突"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "集中存储的附属文件"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
|
||||
+169
-12
@@ -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": "清理範例圖片資料夾",
|
||||
@@ -251,7 +256,6 @@
|
||||
"modelname": "模型名稱",
|
||||
"tags": "標籤",
|
||||
"creator": "創作者",
|
||||
"hash": "雜湊",
|
||||
"title": "配方標題",
|
||||
"loraName": "LoRA 檔案名稱",
|
||||
"loraModel": "LoRA 模型名稱",
|
||||
@@ -293,7 +297,9 @@
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺少的 LoRA",
|
||||
"availabilityDeleted": "包含已刪除的 LoRA"
|
||||
"availabilityDeleted": "包含已刪除的 LoRA",
|
||||
"workflow": "工作流",
|
||||
"hasWorkflow": "包含工作流"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切換主題",
|
||||
@@ -353,6 +359,14 @@
|
||||
"placeholder": "留空則使用 PATH 中的 aria2c"
|
||||
},
|
||||
"aria2HelpLink": "了解如何設定 aria2 下載後端",
|
||||
"unknownBaseModelRouting": {
|
||||
"label": "未知基礎模型路由",
|
||||
"help": "當 CivitAI 回報的基礎模型既不是已知的 Checkpoint(SD 1.x/2.x/3.x、SDXL、Pony、Illustrious、NoobAI),也不是已知的擴散模型時,決定 Checkpoint 下載的儲存位置。新的擴散架構頻繁出現,因此將其路由到擴散模型通常是正確的。",
|
||||
"options": {
|
||||
"diffusionModel": "擴散模型",
|
||||
"checkpoint": "Checkpoint"
|
||||
}
|
||||
},
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 CivitAI 站點偏好設定",
|
||||
"content": "CivitAI 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。您可以在設定中變更預設開啟的站點。",
|
||||
@@ -380,9 +394,11 @@
|
||||
"downloadPathTemplates": "下載路徑範本",
|
||||
"priorityTags": "優先標籤",
|
||||
"versionScope": "版本範圍",
|
||||
"priceTracking": "Buzz 下載價格",
|
||||
"exampleImages": "範例圖片",
|
||||
"autoOrganize": "自動整理",
|
||||
"metadata": "中繼資料",
|
||||
"sidecarStorage": "附屬檔案儲存",
|
||||
"proxySettings": "代理設定"
|
||||
},
|
||||
"nav": {
|
||||
@@ -517,6 +533,12 @@
|
||||
"hover": "滑鼠懸停顯示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||
"showcaseLayout": "範例圖片版面",
|
||||
"showcaseLayoutHelp": "選擇範例圖片在模型彈窗中的顯示方式:一次一張的圖庫,或傳統的垂直清單。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "圖庫",
|
||||
"vertical": "垂直清單"
|
||||
},
|
||||
"showVersionOnCard": "在卡片上顯示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
|
||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||
@@ -745,6 +767,13 @@
|
||||
"label": "隱藏付費更新",
|
||||
"help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
|
||||
},
|
||||
"priceTracking": {
|
||||
"help": "CivitAI 的公開 API 不提供下載價格,因此價格從模型頁面讀取 —— 每個付費模型多一次請求,並依下方間隔重新整理。價格只顯示在您尚未擁有的版本上。",
|
||||
"enabled": "顯示付費版本的下載價格",
|
||||
"enabledHelp": "從模型頁面讀取下載價格,讓付費與搶先體驗版本顯示所需費用,以及搶先體驗的結束時間",
|
||||
"refreshHours": "價格重新整理間隔(小時)",
|
||||
"refreshHoursHelp": "已儲存的價格在重新讀取前保持有效的時長"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版許可協議圖標",
|
||||
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
|
||||
@@ -784,11 +813,43 @@
|
||||
"downloadComplete": "下載成功完成",
|
||||
"enableCivarchiveApi": "啟用 CivArchive API 作為中繼資料提供者",
|
||||
"enableCivarchiveApiHelp": "開啟後,CivArchive API 將作為模型中繼資料的備用來源(例如用於已從 CivitAI 刪除的模型)。關閉可完全避免 CivArchive 的速率限制。",
|
||||
"enableOpenmodeldbApi": "啟用 OpenModelDB 作為中繼資料提供者",
|
||||
"enableOpenmodeldbApiHelp": "開啟後,當 CivitAI 沒有記錄時,也會在 OpenModelDB 目錄(openmodeldb.info)中查詢 Upscaler 中繼資料。目錄會快取在本機並每日更新。",
|
||||
"providerOrder": "中繼資料提供者回退順序",
|
||||
"providerOrderHelp": "CivitAI API 始終優先嘗試。選擇查詢中繼資料時其餘提供者的順序。",
|
||||
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
|
||||
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
|
||||
},
|
||||
"sidecarStorage": {
|
||||
"mode": "附屬檔案儲存模式",
|
||||
"modeHelp": "選擇 .metadata.json 附屬檔案與預覽圖片的存放位置:與每個模型檔案放在一起,或集中在一個對應模型庫結構的目錄中。.civitai.info 檔案一律與模型檔案放在一起。",
|
||||
"modeOptions": {
|
||||
"alongside": "與模型檔案放在一起(預設)",
|
||||
"centralized": "集中儲存"
|
||||
},
|
||||
"path": "集中儲存路徑",
|
||||
"pathHelp": "集中儲存附屬檔案的根目錄。留空則使用預設位置(<settings dir>/sidecars)。",
|
||||
"pathPlaceholder": "留空 = <settings dir>/sidecars",
|
||||
"management": "附屬檔案遷移",
|
||||
"managementHelp": "在「與模型檔案放在一起」與「集中儲存」之間遷移現有的 .metadata.json 附屬檔案與預覽圖片,以符合目前選擇的模式。變更模式不會自動遷移現有檔案。",
|
||||
"migrateButton": "立即遷移附屬檔案",
|
||||
"migratingButton": "遷移中...",
|
||||
"migrating": "正在遷移附屬檔案...",
|
||||
"migrateFailed": "附屬檔案遷移失敗:{message}",
|
||||
"migrationDeferred": "現有附屬檔案未遷移。您稍後可以在「設定 > 模型庫 > 附屬檔案儲存」中遷移它們。",
|
||||
"confirmToCentralized": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移入集中儲存目錄嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
|
||||
"confirmToAlongside": "儲存模式已變更,但現有的 .metadata.json 附屬檔案與預覽圖片不會自動遷移。要現在將它們移回各自的模型檔案旁邊嗎?您也可以稍後使用「立即遷移附屬檔案」按鈕完成。",
|
||||
"confirmToCentralizedManual": "要現在將現有的 .metadata.json 附屬檔案與預覽圖片移入集中儲存目錄嗎?",
|
||||
"confirmToAlongsideManual": "要現在將現有的 .metadata.json 附屬檔案與預覽圖片移回各自的模型檔案旁邊嗎?",
|
||||
"confirmRelocateRoot": "集中儲存目錄已變更,但現有的附屬檔案與預覽圖片仍在原目錄中。要現在將它們移到新目錄嗎?",
|
||||
"effectivePathLabel": "實際儲存位置:",
|
||||
"openFolderButton": "開啟資料夾",
|
||||
"repoWarning": "實際儲存位置位於 LoRA Manager 安裝目錄內。重新安裝外掛或乾淨更新可能會將其刪除——請在安裝目錄之外設定明確的儲存路徑。",
|
||||
"openLocationSuccess": "已開啟附屬檔案儲存資料夾",
|
||||
"openLocationCopied": "附屬檔案儲存路徑已複製到剪貼簿:{path}",
|
||||
"openLocationClipboardFallback": "請手動複製附屬檔案儲存路徑:{path}",
|
||||
"openLocationFailed": "無法開啟附屬檔案儲存資料夾"
|
||||
},
|
||||
"proxySettings": {
|
||||
"enableProxy": "啟用應用程式代理",
|
||||
"enableProxyHelp": "啟用此應用程式的自訂代理設定,將覆蓋系統代理設定",
|
||||
@@ -861,7 +922,10 @@
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。",
|
||||
"scopeSection": "只掃描一個資料夾",
|
||||
"rootOffline": "離線",
|
||||
"rootModels": "{count} 個模型"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "從 CivitAI 取得 metadata",
|
||||
@@ -1009,7 +1073,8 @@
|
||||
"sendWorkflow": "傳送工作流到 ComfyUI",
|
||||
"sent": "工作流已傳送到 ComfyUI",
|
||||
"sendFailed": "傳送工作流到 ComfyUI 失敗",
|
||||
"noWorkflow": "此配方中未找到內嵌工作流"
|
||||
"noWorkflow": "此配方中未找到內嵌工作流",
|
||||
"hasWorkflow": "包含 ComfyUI 工作流"
|
||||
},
|
||||
"status": {
|
||||
"ready": "可直接使用",
|
||||
@@ -1350,7 +1415,20 @@
|
||||
"emptyNote": "該資料夾中沒有模型,其中的其他檔案也會一併刪除。",
|
||||
"notEmptyTitle": "資料夾不是空的",
|
||||
"notEmptyMessage": "該資料夾中仍有模型,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"confirm": "刪除資料夾"
|
||||
"notEmptyMessageCount": "該資料夾中仍有 {count} 個模型檔案,請先刪除或移出這些模型 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"notEmptyMessageExcluded": "該資料夾中仍有 {count} 個模型檔案,其中 {excluded} 個已從模型庫中排除。請先在「管理已排除的模型」中取消排除並刪除它們 —— 刪除資料夾不會串聯刪除模型檔案。",
|
||||
"missingTitle": "未找到資料夾",
|
||||
"missingMessage": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
|
||||
"symlinkTitle": "資料夾是符號連結",
|
||||
"symlinkMessage": "該資料夾的每一份副本都是符號連結;請在 LoRA Manager 之外刪除該連結或其指向的目標。",
|
||||
"protectedTitle": "資料夾受保護",
|
||||
"protectedMessage": "此資料夾存放著配方庫,無法在 LoRA Manager 中刪除。",
|
||||
"busyTitle": "仍有刪除操作待處理",
|
||||
"checking": "正在檢查資料夾內容...",
|
||||
"confirm": "刪除資料夾",
|
||||
"confirmMulti": "刪除 {count} 個資料夾",
|
||||
"deleting": "正在刪除...",
|
||||
"keptNote": "未勾選的副本會讓該資料夾繼續顯示在側邊欄中。"
|
||||
},
|
||||
"deleteFolderResult": {
|
||||
"success": "已刪除資料夾 \"{name}\"",
|
||||
@@ -1358,9 +1436,13 @@
|
||||
"restored": "資料夾已還原",
|
||||
"failed": "刪除資料夾失敗: {message}",
|
||||
"notEmpty": "該資料夾中仍有模型。請重新整理側邊欄後再試。",
|
||||
"notEmptyWithCount": "該資料夾中仍有 {count} 個模型檔案。請重新整理側邊欄後再試。",
|
||||
"busy": "該資料夾內仍有待處理的刪除操作,請等待復原時間結束。",
|
||||
"unsupported": "此頁面不支援刪除資料夾",
|
||||
"noRoot": "未設定模型根目錄"
|
||||
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。",
|
||||
"symlink": "該資料夾是符號連結,無法從側邊欄中刪除。",
|
||||
"successMulti": "已從 {count} 個模型根目錄中刪除資料夾 \"{name}\"",
|
||||
"partial": "已刪除 {total} 份副本中的 {count} 份 —— {failed} 份無法刪除。請重新整理側邊欄後再試。"
|
||||
},
|
||||
"renameFolder": "重新命名資料夾",
|
||||
"renameFolderResult": {
|
||||
@@ -1369,7 +1451,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": "無法確定移動的目標路徑。",
|
||||
@@ -1389,6 +1490,10 @@
|
||||
"success": "在此資料夾中找到 {count} 個{type}更新",
|
||||
"none": "此資料夾中的所有{type}都是最新版本",
|
||||
"error": "檢查資料夾{type}更新失敗: {message}"
|
||||
},
|
||||
"scanFolder": "掃描此資料夾",
|
||||
"scanFolderResult": {
|
||||
"missing": "該資料夾在磁碟上已不存在,請重新整理側邊欄後再試。"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1521,6 +1626,10 @@
|
||||
"selectRootDirectory": "選擇根目錄",
|
||||
"selectModelRoot": "選擇模型根目錄:",
|
||||
"selectTypeRoot": "選擇 {type} 根目錄:",
|
||||
"routingOverride": {
|
||||
"label": "目標類型:",
|
||||
"tooltip": "根據模型中繼資料自動偵測。如果偵測結果有誤,請切換;切換會為本次下載關閉「使用預設路徑」。"
|
||||
},
|
||||
"targetFolderPath": "目標資料夾路徑:",
|
||||
"browseFolders": "瀏覽資料夾:",
|
||||
"createNewFolder": "建立新資料夾",
|
||||
@@ -1637,6 +1746,29 @@
|
||||
"titleRevert": "要還原原始檔案名稱嗎?",
|
||||
"revertButton": "還原原始檔案名稱"
|
||||
},
|
||||
"sidecarMigrationConfirm": {
|
||||
"titleToCentralized": "要將附屬檔案移到集中儲存嗎?",
|
||||
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
|
||||
"confirmButton": "立即遷移",
|
||||
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
|
||||
"destination": "目標位置:{path}",
|
||||
"defaultLocationHint": "將使用預設儲存位置。如需使用自訂資料夾,請取消,在上方設定儲存路徑,然後點擊「立即遷移附屬檔案」。"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "附屬檔案遷移摘要",
|
||||
"completedSuccessfully": "遷移已成功完成",
|
||||
"completedWithErrors": "完成,但有 {count} 個錯誤",
|
||||
"statMoved": "已遷移檔案",
|
||||
"statModels": "模型",
|
||||
"statSkipped": "已跳過",
|
||||
"statConflicts": "已解決衝突",
|
||||
"statErrors": "錯誤",
|
||||
"failedItems": "失敗項目({count})",
|
||||
"columnModel": "模型",
|
||||
"columnError": "錯誤",
|
||||
"successMessage": "已為 {models} 個模型遷移 {moved} 個檔案",
|
||||
"location": "儲存位置:{path}"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
@@ -1766,9 +1898,12 @@
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"openSidecarLocation": "開啟中繼資料位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||
"copyHash": "複製雜湊值",
|
||||
"copyCivitaiId": "複製 Civitai ID",
|
||||
"civitaiIdCopied": "Civitai ID 已複製到剪貼簿",
|
||||
"deleteModelWithShortcut": "刪除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
@@ -1777,6 +1912,12 @@
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
},
|
||||
"openSidecarLocation": {
|
||||
"success": "中繼資料位置已成功開啟",
|
||||
"failed": "開啟中繼資料位置失敗",
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"noFilePath": "無法傳送到 ComfyUI:沒有可用的檔案路徑"
|
||||
},
|
||||
@@ -1787,6 +1928,8 @@
|
||||
"baseModel": "基礎模型",
|
||||
"size": "大小",
|
||||
"hashes": "雜湊值",
|
||||
"civitaiModelId": "模型 ID",
|
||||
"civitaiVersionId": "版本 ID",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加備註",
|
||||
@@ -1904,7 +2047,9 @@
|
||||
"nsfwMature": "成熟內容",
|
||||
"nsfwR": "R 級內容",
|
||||
"nsfwX": "X 級內容",
|
||||
"nsfwXxx": "XXX 級內容"
|
||||
"nsfwXxx": "XXX 級內容",
|
||||
"layoutGallery": "圖庫檢視",
|
||||
"layoutList": "清單檢視"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
@@ -1935,10 +2080,15 @@
|
||||
"earlyAccessTooltip": "此版本目前需要 CivitAI 搶先體驗權限",
|
||||
"paid": "付費",
|
||||
"paidTooltip": "此版本需要付費才能下載",
|
||||
"priceSaleTooltip": "特價中,原價 {list} Buzz",
|
||||
"priceBlueBuzzTooltip": "也可以使用 Blue Buzz 支付",
|
||||
"freeNow": "現已免費",
|
||||
"freeNowTooltip": "此版本已不再需要付費",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知",
|
||||
"onSiteOnly": "僅站內生成",
|
||||
"onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載"
|
||||
"onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載",
|
||||
"earlyAccessTooltipUntil": "搶先體驗至 {date},之後可免費下載"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
@@ -1993,6 +2143,7 @@
|
||||
"statSuccess": "成功",
|
||||
"statFailed": "失敗",
|
||||
"statSkipped": "已跳過",
|
||||
"statOffline": "磁碟機離線",
|
||||
"statTotal": "總計掃描",
|
||||
"statDuration": "耗時",
|
||||
"successMessage": "全部 {count} 個 {type} 更新成功!",
|
||||
@@ -2677,7 +2828,10 @@
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "移動項目失敗:{message}",
|
||||
"copiedToClipboard": "已複製到剪貼簿",
|
||||
"downloadStarted": "下載已開始"
|
||||
"downloadStarted": "下載已開始",
|
||||
"refreshCompleteScoped": "已掃描 {scope}:新增 {added},移除 {removed}",
|
||||
"refreshKeptUnreachable": "已保留 {count} 個模型:{paths} 目前無法存取",
|
||||
"scanRootUnreachable": "{scope} 目前無法存取,未做任何變更。"
|
||||
},
|
||||
"agent": {
|
||||
"llmNotConfigured": "AI 提供者尚未設定。請在 設定 → AI 提供者 中進行設定。",
|
||||
@@ -2713,6 +2867,9 @@
|
||||
"filename_conflicts": {
|
||||
"title": "檔案名稱重複衝突"
|
||||
},
|
||||
"sidecar_mirror_orphans": {
|
||||
"title": "集中儲存的附屬檔案"
|
||||
},
|
||||
"ui_version": {
|
||||
"title": "UI 版本"
|
||||
}
|
||||
|
||||
+310
-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
|
||||
@@ -891,6 +974,17 @@ class Config:
|
||||
if self.recipes_path:
|
||||
preview_roots.update(self._expand_preview_root(self.recipes_path))
|
||||
|
||||
# Centralized sidecar storage holds preview assets outside the model
|
||||
# roots; allow serving them when the mode is active.
|
||||
try:
|
||||
from .utils.sidecar_paths import get_sidecar_root # Local import to avoid circular dependency
|
||||
|
||||
sidecar_root = get_sidecar_root()
|
||||
except Exception: # pragma: no cover - defensive fallback
|
||||
sidecar_root = ""
|
||||
if sidecar_root:
|
||||
preview_roots.update(self._expand_preview_root(sidecar_root))
|
||||
|
||||
for target, link in self._path_mappings.items():
|
||||
preview_roots.update(self._expand_preview_root(target))
|
||||
preview_roots.update(self._expand_preview_root(link))
|
||||
@@ -1310,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,
|
||||
@@ -1322,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,
|
||||
@@ -1377,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:"
|
||||
@@ -1401,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,
|
||||
@@ -1427,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:"
|
||||
@@ -1464,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,
|
||||
@@ -1484,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.
|
||||
|
||||
@@ -1494,6 +1788,15 @@ class Config:
|
||||
self.other_roots = self._init_other_paths()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def refresh_preview_roots(self) -> None:
|
||||
"""Rebuild the preview allowlist after path-affecting settings change.
|
||||
|
||||
Called when ``sidecar_storage_mode`` / ``sidecar_storage_path`` are
|
||||
updated so centralized preview assets become servable (or stop being
|
||||
servable) without a restart.
|
||||
"""
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def get_other_models_availability(self) -> Dict[str, Any]:
|
||||
"""Report the other-model folders the host can actually expose.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from .constants import IMAGES
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER, OVERWRITE
|
||||
from .node_extractors import NODE_EXTRACTORS
|
||||
from .node_extractors import NODE_EXTRACTORS, _get_variable_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -356,8 +356,18 @@ class MetadataProcessor:
|
||||
# Handle pipe nodes like FromBasicPipe by following the pipeline
|
||||
next_input_name = "basic_pipe"
|
||||
else:
|
||||
# Dead end - no model input to follow
|
||||
return None
|
||||
# No direct model input to follow. GetNode (KJNodes) links
|
||||
# are virtual and absent from the API prompt — resolve the
|
||||
# variable reference back to the SetNode's MODEL source
|
||||
# before giving up.
|
||||
resolved_id = MetadataProcessor._resolve_model_variable_source(
|
||||
metadata, prompt, current_node_id
|
||||
)
|
||||
if resolved_id is None:
|
||||
return None
|
||||
current_node_id = resolved_id
|
||||
depth += 1
|
||||
continue
|
||||
|
||||
# Get connected node
|
||||
input_val = inputs[next_input_name]
|
||||
@@ -370,6 +380,57 @@ class MetadataProcessor:
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_model_variable_source(metadata, prompt, node_id):
|
||||
"""Resolve a GetNode-style virtual reference to its MODEL source node.
|
||||
|
||||
SetNodeExtractor records ``model_variable`` entries (variable name →
|
||||
source node id) when a SetNode carrying a MODEL link executes, and
|
||||
GetNodeExtractor records the variable name each GetNode reads. When
|
||||
both are available, the virtual Set/Get link can be followed just like
|
||||
a real connection.
|
||||
"""
|
||||
if not prompt or not getattr(prompt, "original_prompt", None):
|
||||
return None
|
||||
if node_id not in prompt.original_prompt:
|
||||
return None
|
||||
|
||||
# Variable name read by this node: prefer the runtime record,
|
||||
# fall back to the prompt inputs.
|
||||
variable_name = None
|
||||
prompt_info = metadata.get(PROMPTS, {}).get(node_id)
|
||||
if isinstance(prompt_info, dict):
|
||||
variable_name = prompt_info.get("variable_name")
|
||||
if not variable_name:
|
||||
node_inputs = prompt.original_prompt[node_id].get("inputs", {})
|
||||
variable_name = _get_variable_name(node_inputs)
|
||||
if not variable_name:
|
||||
return None
|
||||
|
||||
candidates = [
|
||||
info
|
||||
for info in metadata.get(MODELS, {}).values()
|
||||
if isinstance(info, dict)
|
||||
and info.get("type") == "model_variable"
|
||||
and info.get("variable_name") == variable_name
|
||||
and info.get("source_node_id")
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# KJNodes semantics: when several SetNodes share a variable name,
|
||||
# the last one to execute wins.
|
||||
execution_order = metadata.get("execution_order") or []
|
||||
|
||||
def _order(info):
|
||||
try:
|
||||
return execution_order.index(info.get("node_id"))
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
best = max(candidates, key=_order)
|
||||
return best.get("source_node_id")
|
||||
|
||||
@staticmethod
|
||||
def find_primary_checkpoint(metadata, downstream_id=None, primary_sampler_id=None):
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from nodes import NODE_CLASS_MAPPINGS # pyright: ignore[reportMissingImports, reportAttributeAccessIssue]
|
||||
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
||||
from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
|
||||
from .constants import METADATA_CATEGORIES, IMAGES, MODELS, OVERWRITE
|
||||
|
||||
|
||||
# Input fields that carry a model filename in loader-style nodes. Mirrors
|
||||
# GenericNodeExtractor._MODEL_NAME_FIELDS, plus the TensorRT engine extension.
|
||||
_MODEL_NAME_FIELDS = ("ckpt_name", "unet_name", "model_path", "model_name", "gguf_name")
|
||||
_MODEL_FILE_EXTENSIONS = (
|
||||
".ckpt", ".pt", ".pt2", ".bin", ".pth",
|
||||
".safetensors", ".pkl", ".sft", ".gguf", ".engine",
|
||||
)
|
||||
|
||||
|
||||
def _checkpoint_name_candidates(inputs):
|
||||
"""Derive the checkpoint names a loader node's current inputs could record.
|
||||
|
||||
Used to keep stale ``node_cache`` entries (from before the user switched
|
||||
models) out of prompts they no longer belong to. Returns an empty set when
|
||||
the inputs carry no recognizable model field.
|
||||
"""
|
||||
candidates = set()
|
||||
for field in _MODEL_NAME_FIELDS:
|
||||
value = inputs.get(field)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
name = value.strip()
|
||||
if not name.lower().endswith(_MODEL_FILE_EXTENSIONS):
|
||||
continue
|
||||
candidates.add(name)
|
||||
base = os.path.splitext(os.path.basename(name))[0]
|
||||
candidates.add(base)
|
||||
# TensorRTLoaderExtractor derivation: drop the "_$profile" part and
|
||||
# any trailing save counter (e.g. "_00001_").
|
||||
derived = base
|
||||
if "_$" in derived:
|
||||
derived = derived[: derived.index("_$")]
|
||||
derived = re.sub(r"_\d+_?$", "", derived)
|
||||
candidates.add(derived)
|
||||
return candidates
|
||||
|
||||
|
||||
class MetadataRegistry:
|
||||
@@ -155,9 +194,56 @@ class MetadataRegistry:
|
||||
continue
|
||||
if category in cached_data and node_id in cached_data[category]:
|
||||
if node_id not in metadata[category]:
|
||||
metadata[category][node_id] = cached_data[category][
|
||||
node_id
|
||||
]
|
||||
cached_entry = cached_data[category][node_id]
|
||||
if category == MODELS:
|
||||
cached_entry = self._validated_checkpoint_fill(
|
||||
cached_entry, node_data
|
||||
)
|
||||
if cached_entry is None:
|
||||
continue
|
||||
metadata[category][node_id] = cached_entry
|
||||
|
||||
@staticmethod
|
||||
def _validated_checkpoint_fill(cached_entry, node_data):
|
||||
"""Guard a MODELS cache fill against stale checkpoint names.
|
||||
|
||||
A loader that has not executed since the user switched models still
|
||||
holds the previous model in its cache entry. When the node's current
|
||||
inputs name a different file, the cached name is replaced with the one
|
||||
derived from the current inputs; when no current name can be derived,
|
||||
the stale entry is dropped entirely.
|
||||
|
||||
The cache is trusted as-is when:
|
||||
* the entry is not a checkpoint record,
|
||||
* the cached name carries no model-file extension (e.g. names set
|
||||
from runtime attachments rather than a widget value), or
|
||||
* the node inputs carry no recognizable model field (unknown loader).
|
||||
"""
|
||||
if not isinstance(cached_entry, dict):
|
||||
return cached_entry
|
||||
if cached_entry.get("type") != "checkpoint":
|
||||
return cached_entry
|
||||
cached_name = cached_entry.get("name")
|
||||
if not cached_name or not cached_name.lower().endswith(_MODEL_FILE_EXTENSIONS):
|
||||
return cached_entry
|
||||
|
||||
inputs = node_data.get("inputs", {})
|
||||
candidates = _checkpoint_name_candidates(inputs)
|
||||
if not candidates:
|
||||
return cached_entry
|
||||
if cached_name in candidates:
|
||||
return cached_entry
|
||||
|
||||
# Stale — rebuild from the current inputs when possible.
|
||||
for field in _MODEL_NAME_FIELDS:
|
||||
value = inputs.get(field)
|
||||
if isinstance(value, str) and value.strip().lower().endswith(
|
||||
_MODEL_FILE_EXTENSIONS
|
||||
):
|
||||
rebuilt = dict(cached_entry)
|
||||
rebuilt["name"] = value.strip()
|
||||
return rebuilt
|
||||
return None
|
||||
|
||||
def record_node_execution(self, node_id, class_type, inputs, outputs, return_types=None):
|
||||
"""Record information about a node's execution"""
|
||||
|
||||
@@ -584,6 +584,20 @@ class ConditioningCombineExtractor(NodeMetadataExtractor):
|
||||
)
|
||||
|
||||
|
||||
def _get_model_link_source(metadata, node_id):
|
||||
"""Return the upstream node id feeding this node's MODEL input link."""
|
||||
prompt = metadata.get("current_prompt")
|
||||
original_prompt = getattr(prompt, "original_prompt", None)
|
||||
if not original_prompt or node_id not in original_prompt:
|
||||
return None
|
||||
node_inputs = original_prompt[node_id].get("inputs", {})
|
||||
for key in ("MODEL", "model"):
|
||||
link = node_inputs.get(key)
|
||||
if isinstance(link, list) and link:
|
||||
return str(link[0])
|
||||
return None
|
||||
|
||||
|
||||
class SetNodeExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
@@ -591,6 +605,20 @@ class SetNodeExtractor(NodeMetadataExtractor):
|
||||
return
|
||||
|
||||
variable_name = _get_node_variable_name(metadata, node_id, inputs)
|
||||
|
||||
# Record MODEL passthrough as a variable reference so trace_model_path
|
||||
# can follow the matching GetNode back to the real loader — Set/Get
|
||||
# links are virtual and absent from the API prompt.
|
||||
if variable_name:
|
||||
source_node_id = _get_model_link_source(metadata, node_id)
|
||||
if source_node_id is not None:
|
||||
metadata.setdefault(MODELS, {})[node_id] = {
|
||||
"type": "model_variable",
|
||||
"variable_name": variable_name,
|
||||
"source_node_id": source_node_id,
|
||||
"node_id": node_id,
|
||||
}
|
||||
|
||||
conditioning = inputs.get("CONDITIONING")
|
||||
if conditioning is None:
|
||||
conditioning = inputs.get("conditioning")
|
||||
|
||||
@@ -172,12 +172,16 @@ async def download_preview(
|
||||
"""
|
||||
from ..services.downloader import get_downloader
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.sidecar_paths import get_preview_dir
|
||||
|
||||
if not url or not url.strip():
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
preview_dir = os.path.dirname(model_path)
|
||||
preview_dir = get_preview_dir(model_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own directory
|
||||
# in alongside mode).
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
output_path = os.path.join(preview_dir, base_name + ".webp")
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
@@ -118,9 +118,16 @@ class LoadImageMetadataLM:
|
||||
"Connect lora_stack to Lora Loader. Convert loader/sampler widgets to inputs for the other outputs. "
|
||||
"Extraction failures use starter defaults and are shown as ERROR messages in readable_report."
|
||||
)
|
||||
# model_name, sampler_name and scheduler select a value from a loader or
|
||||
# sampler dropdown. They must stay untyped (Any, "*"): ComfyUI rejects a
|
||||
# "COMBO" (and a "STRING") output linked into the classic list-style combo
|
||||
# inputs used by Load Checkpoint, KSampler, and the LoRA Manager loaders
|
||||
# (comfy_execution/validation.py refuses a non-string input type), which
|
||||
# surfaced as "Return type mismatch between linked nodes" at queue time.
|
||||
# "*" is the same type ComfyUI's own Primitive node uses to feed widgets.
|
||||
RETURN_TYPES = (
|
||||
"IMAGE", "MASK", "STRING", "STRING", "COMBO", "LORA_STACK", "STRING",
|
||||
"INT", "INT", "FLOAT", "COMBO", "COMBO", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
|
||||
"IMAGE", "MASK", "STRING", "STRING", "*", "LORA_STACK", "STRING",
|
||||
"INT", "INT", "FLOAT", "*", "*", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"image", "mask", "positive", "negative", "model_name", "lora_stack", "lora_stack_text",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -55,6 +55,11 @@ class DownloadRoutingHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
# CivitAI ModelType.UNet downloads go through the checkpoint branch,
|
||||
# same as in the download manager.
|
||||
if model_type.lower() == "unet":
|
||||
model_type = "checkpoint"
|
||||
|
||||
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
|
||||
@@ -96,10 +101,15 @@ class DownloadRoutingHandler:
|
||||
}
|
||||
)
|
||||
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
|
||||
is_diffusion = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(str(t) for t in file_types),
|
||||
base_model=base_model,
|
||||
unknown_base_model_default=get_settings_manager().get(
|
||||
"unknown_base_model_routing", "diffusion_model"
|
||||
),
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote
|
||||
from typing import Any, Awaitable, Callable, Dict, Mapping, Protocol, Sequence
|
||||
|
||||
from aiohttp import web
|
||||
@@ -45,7 +46,10 @@ from ...services.llm_service import (
|
||||
get_provider_model_ids,
|
||||
)
|
||||
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
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,
|
||||
@@ -68,6 +72,15 @@ from ...utils.example_images_paths import (
|
||||
)
|
||||
from ...utils.lora_metadata import extract_trained_words
|
||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||
from ...utils.sidecar_paths import (
|
||||
describe_sidecar_root,
|
||||
get_configured_sidecar_root,
|
||||
get_metadata_path,
|
||||
get_preview_dir,
|
||||
get_sidecar_dir,
|
||||
get_storage_mode,
|
||||
get_unmatched_sidecar_components,
|
||||
)
|
||||
from ...utils.usage_stats import UsageStats
|
||||
from .base_model_handlers import BaseModelHandlerSet
|
||||
|
||||
@@ -428,6 +441,35 @@ def _has_gui_display() -> bool:
|
||||
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
|
||||
|
||||
def _select_in_linux_file_manager(file_path: str) -> bool:
|
||||
"""Reveal ``file_path`` selected in the desktop file manager.
|
||||
|
||||
Uses the freedesktop ``org.freedesktop.FileManager1.ShowItems`` DBus
|
||||
interface, which is supported by GNOME Files (Nautilus), KDE Dolphin,
|
||||
Nemo, Caja, and recent Thunar. Returns ``False`` when the interface is
|
||||
unavailable so the caller can fall back to plain ``xdg-open``.
|
||||
"""
|
||||
uri = "file://" + quote(file_path)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"dbus-send",
|
||||
"--session",
|
||||
"--print-reply",
|
||||
"--dest=org.freedesktop.FileManager1",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
f"array:string:{uri}",
|
||||
"string:",
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
class PromptServerProtocol(Protocol):
|
||||
"""Subset of PromptServer used by the handlers."""
|
||||
|
||||
@@ -798,6 +840,7 @@ class DoctorHandler:
|
||||
await self._check_civitai_api_key(),
|
||||
await self._check_cache_health(),
|
||||
await self._check_filename_conflicts(),
|
||||
self._check_sidecar_mirror_orphans(),
|
||||
self._check_ui_version(client_version, app_version),
|
||||
]
|
||||
|
||||
@@ -943,15 +986,24 @@ class DoctorHandler:
|
||||
|
||||
os.rename(path, new_path)
|
||||
|
||||
for suffix in (".metadata.json", ".civitai.info"):
|
||||
old_sidecar = old_base_no_ext + suffix
|
||||
new_sidecar = new_base_no_ext + suffix
|
||||
if os.path.exists(old_sidecar):
|
||||
os.rename(old_sidecar, new_sidecar)
|
||||
old_metadata_path = get_metadata_path(path)
|
||||
new_metadata_path = get_metadata_path(new_path)
|
||||
if os.path.exists(old_metadata_path):
|
||||
os.rename(old_metadata_path, new_metadata_path)
|
||||
|
||||
old_sidecar = old_base_no_ext + ".civitai.info"
|
||||
new_sidecar = new_base_no_ext + ".civitai.info"
|
||||
if os.path.exists(old_sidecar):
|
||||
os.rename(old_sidecar, new_sidecar)
|
||||
|
||||
for preview_ext in PREVIEW_EXTENSIONS:
|
||||
old_preview = old_base_no_ext + preview_ext
|
||||
new_preview = new_base_no_ext + preview_ext
|
||||
old_preview = os.path.join(
|
||||
get_preview_dir(path), base_name + preview_ext
|
||||
)
|
||||
new_preview = os.path.join(
|
||||
get_preview_dir(new_path),
|
||||
candidate_base + preview_ext,
|
||||
)
|
||||
if os.path.exists(old_preview):
|
||||
os.rename(old_preview, new_preview)
|
||||
|
||||
@@ -963,7 +1015,10 @@ class DoctorHandler:
|
||||
old_preview_url = entry["preview_url"].replace("\\", "/")
|
||||
preview_ext = os.path.splitext(old_preview_url)[1]
|
||||
if preview_ext:
|
||||
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
|
||||
entry["preview_url"] = os.path.join(
|
||||
get_preview_dir(new_path),
|
||||
candidate_base + preview_ext,
|
||||
).replace(os.sep, "/")
|
||||
await scanner.update_single_model_cache(
|
||||
path, new_path, entry
|
||||
)
|
||||
@@ -1022,6 +1077,71 @@ class DoctorHandler:
|
||||
logger.error("Error exporting doctor bundle: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
def _check_sidecar_mirror_orphans(self) -> dict[str, Any]:
|
||||
"""Flag centralized sidecars stranded by a moved/removed model root.
|
||||
|
||||
Centralized sidecars live under a per-root mirror directory. A root
|
||||
that was moved, renamed, or dropped from the configuration leaves its
|
||||
mirror behind; without this check the loss is silent, because the
|
||||
scanner simply rebuilds default metadata at the new location.
|
||||
"""
|
||||
|
||||
actions = [{"id": "open-settings", "label": "Open Settings"}]
|
||||
try:
|
||||
mode = get_storage_mode()
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug("Doctor: sidecar mode lookup failed: %s", exc)
|
||||
mode = "alongside"
|
||||
|
||||
if mode != "centralized":
|
||||
return {
|
||||
"id": "sidecar_mirror_orphans",
|
||||
"title": "Centralized Sidecars",
|
||||
"status": "ok",
|
||||
"summary": "Sidecar metadata is stored alongside the models.",
|
||||
"details": [],
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
try:
|
||||
orphans = get_unmatched_sidecar_components()
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.warning("Doctor: sidecar orphan check failed: %s", exc)
|
||||
orphans = []
|
||||
|
||||
if not orphans:
|
||||
return {
|
||||
"id": "sidecar_mirror_orphans",
|
||||
"title": "Centralized Sidecars",
|
||||
"status": "ok",
|
||||
"summary": "Every mirrored sidecar directory is linked to a model root.",
|
||||
"details": [f"Root: {describe_sidecar_root().get('root', '')}"],
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
details = [
|
||||
"Metadata (favorites, notes, tags, usage tips) for these models is on disk but is not being read.",
|
||||
"This usually means a model root was moved, renamed, or removed. Restore the original root path in Settings; the mirror is re-linked automatically.",
|
||||
]
|
||||
details.extend(
|
||||
f"{item['component']} — last known root: {item['last_path'] or 'unknown'}"
|
||||
for item in orphans[:5]
|
||||
)
|
||||
if len(orphans) > 5:
|
||||
details.append(f"… and {len(orphans) - 5} more")
|
||||
return {
|
||||
"id": "sidecar_mirror_orphans",
|
||||
"title": "Centralized Sidecars",
|
||||
"status": "warning",
|
||||
"summary": (
|
||||
f"{len(orphans)} sidecar "
|
||||
f"director{'y' if len(orphans) == 1 else 'ies'} could not be "
|
||||
"linked to a configured model root."
|
||||
),
|
||||
"details": details,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
async def _check_civitai_api_key(self) -> dict[str, Any]:
|
||||
api_key = (self._settings.get("civitai_api_key", "") or "").strip()
|
||||
if not api_key:
|
||||
@@ -1612,6 +1732,19 @@ class SettingsHandler:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
# Resolved centralized sidecar root (mode-independent): lets the
|
||||
# settings UI show where sidecars actually live, including when the
|
||||
# path setting is empty and the default kicks in. inside_repo flags
|
||||
# the portable-mode hazard (root inside the plugin folder).
|
||||
try:
|
||||
sidecar_info = describe_sidecar_root()
|
||||
response_data["sidecar_storage_root"] = sidecar_info["root"]
|
||||
response_data["sidecar_storage_root_is_default"] = sidecar_info["is_default"]
|
||||
response_data["sidecar_storage_root_in_repo"] = sidecar_info["inside_repo"]
|
||||
except Exception as sidecar_error: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"Could not resolve sidecar storage info: %s", sidecar_error
|
||||
)
|
||||
messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
|
||||
messages = list(messages_getter()) if messages_getter else []
|
||||
return web.json_response(
|
||||
@@ -1706,6 +1839,7 @@ class SettingsHandler:
|
||||
if key in (
|
||||
"enable_metadata_archive_db",
|
||||
"enable_civarchive_api",
|
||||
"enable_openmodeldb_api",
|
||||
"metadata_provider_order",
|
||||
):
|
||||
await self._metadata_provider_updater()
|
||||
@@ -2778,9 +2912,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(
|
||||
@@ -3345,12 +3480,81 @@ class FileSystemHandler:
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", path])
|
||||
else:
|
||||
if not _has_gui_display():
|
||||
# Headless/SSH session: xdg-open cannot open a file
|
||||
# manager, so hand the path to the browser for copying
|
||||
# instead of reporting a success that never happened.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Headless session: path available for copying",
|
||||
"path": path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
subprocess.Popen(["xdg-open", path])
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "message": f"Opened folder: {path}", "path": path}
|
||||
)
|
||||
|
||||
async def _reveal_file(self, file_path: str) -> web.Response:
|
||||
"""Open the platform file manager with ``file_path`` selected."""
|
||||
if os.name == "nt":
|
||||
subprocess.Popen(["explorer", "/select,", file_path])
|
||||
elif os.name == "posix":
|
||||
if _is_docker():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Running in Docker: Path available for copying",
|
||||
"path": file_path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
elif _is_wsl():
|
||||
windows_path = _wsl_to_windows_path(file_path)
|
||||
if windows_path:
|
||||
subprocess.Popen(["explorer.exe", "/select,", windows_path])
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to convert WSL path to Windows path: %s", file_path
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to open file location: path conversion error",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", file_path])
|
||||
else:
|
||||
if not _has_gui_display():
|
||||
# Headless/SSH session: no file manager to open, so
|
||||
# hand the path to the browser for copying instead
|
||||
# of reporting a success that never happened.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Headless session: path available for copying",
|
||||
"path": file_path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
selected = _select_in_linux_file_manager(file_path)
|
||||
if not selected:
|
||||
folder = os.path.dirname(file_path)
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Opened folder and selected file: {file_path}",
|
||||
"path": file_path,
|
||||
}
|
||||
)
|
||||
|
||||
async def open_file_location(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
@@ -3366,49 +3570,49 @@ class FileSystemHandler:
|
||||
{"success": False, "error": "File does not exist"}, status=404
|
||||
)
|
||||
|
||||
if os.name == "nt":
|
||||
subprocess.Popen(["explorer", "/select,", file_path])
|
||||
elif os.name == "posix":
|
||||
if _is_docker():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Running in Docker: Path available for copying",
|
||||
"path": file_path,
|
||||
"mode": "clipboard",
|
||||
}
|
||||
)
|
||||
elif _is_wsl():
|
||||
windows_path = _wsl_to_windows_path(file_path)
|
||||
if windows_path:
|
||||
subprocess.Popen(["explorer.exe", "/select,", windows_path])
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to convert WSL path to Windows path: %s", file_path
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Failed to open file location: path conversion error",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", file_path])
|
||||
else:
|
||||
folder = os.path.dirname(file_path)
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"Opened folder and selected file: {file_path}",
|
||||
}
|
||||
)
|
||||
return await self._reveal_file(file_path)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open file location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_model_sidecar_location(self, request: web.Request) -> web.Response:
|
||||
"""Reveal the model's ``.metadata.json`` sidecar in the file manager.
|
||||
|
||||
Sidecar/preview path derivation must go through
|
||||
:mod:`py.utils.sidecar_paths` so centralized storage mode resolves to
|
||||
the mirror tree. When the sidecar has not been written yet, the
|
||||
containing sidecar directory is opened instead.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get("file_path")
|
||||
if not file_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Missing file_path parameter"},
|
||||
status=400,
|
||||
)
|
||||
file_path = os.path.abspath(file_path)
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "File does not exist"}, status=404
|
||||
)
|
||||
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if os.path.isfile(metadata_path):
|
||||
return await self._reveal_file(metadata_path)
|
||||
|
||||
sidecar_dir = get_sidecar_dir(file_path)
|
||||
if os.path.isdir(sidecar_dir):
|
||||
return await self._open_path(sidecar_dir)
|
||||
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Sidecar does not exist yet"},
|
||||
status=404,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_settings_location(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
@@ -3501,6 +3705,24 @@ class FileSystemHandler:
|
||||
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_sidecar_location(self, request: web.Request) -> web.Response:
|
||||
"""Open the centralized sidecar storage root in the file manager."""
|
||||
|
||||
try:
|
||||
root = get_configured_sidecar_root()
|
||||
if not root:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Sidecar storage root is not resolvable"},
|
||||
status=404,
|
||||
)
|
||||
# Create on demand so the button also works before the first
|
||||
# migration/download has materialized the directory.
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return await self._open_path(root)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def browse_directory(self, request: web.Request) -> web.Response:
|
||||
"""Browse a directory for the settings-UI directory picker."""
|
||||
try:
|
||||
@@ -4123,6 +4345,64 @@ class NodeRegistryHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class SidecarMigrationHandler:
|
||||
"""Migrate sidecar metadata and previews between storage layouts."""
|
||||
|
||||
_VALID_DIRECTIONS = ("to_centralized", "to_alongside", "relocate_root")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
use_case_factory: Callable[[], SidecarMigrationUseCase] = SidecarMigrationUseCase,
|
||||
progress_callback_factory: Callable[[], Any] = WebSocketBroadcastCallback,
|
||||
) -> None:
|
||||
self._use_case_factory = use_case_factory
|
||||
self._progress_callback_factory = progress_callback_factory
|
||||
|
||||
async def migrate_sidecars(self, request: web.Request) -> web.Response:
|
||||
"""Run a sidecar migration; accepts POST JSON or GET query params."""
|
||||
try:
|
||||
if request.method == "GET":
|
||||
params: Mapping[str, Any] = request.query
|
||||
else:
|
||||
try:
|
||||
params = await request.json()
|
||||
except Exception: # empty/invalid body: fall back to query
|
||||
params = request.query
|
||||
|
||||
direction = str(params.get("direction") or "").strip()
|
||||
if direction not in self._VALID_DIRECTIONS:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "direction must be 'to_centralized', 'to_alongside' or 'relocate_root'",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
force = params.get("force") in (True, 1, "true", "1")
|
||||
old_root = str(params.get("old_root") or "").strip()
|
||||
if direction == "relocate_root" and not old_root:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "old_root is required for relocate_root"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
use_case = self._use_case_factory()
|
||||
progress_cb = self._progress_callback_factory()
|
||||
result = await use_case.execute_with_error_handling(
|
||||
direction=direction,
|
||||
progress_cb=progress_cb,
|
||||
force=force,
|
||||
old_root=old_root,
|
||||
)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
logger.error("Sidecar migration failed: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class MiscHandlerSet:
|
||||
"""Aggregate handlers into a lookup compatible with the registrar."""
|
||||
|
||||
@@ -4149,6 +4429,7 @@ class MiscHandlerSet:
|
||||
model_source_handler: Any = None,
|
||||
agent_handler: Any = None,
|
||||
download_routing: Any = None,
|
||||
sidecar_migration: Any = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -4170,6 +4451,7 @@ class MiscHandlerSet:
|
||||
self.model_source_handler = model_source_handler
|
||||
self.agent_handler = agent_handler
|
||||
self.download_routing = download_routing
|
||||
self.sidecar_migration = sidecar_migration
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -4215,6 +4497,8 @@ class MiscHandlerSet:
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"open_backup_location": self.filesystem.open_backup_location,
|
||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||
"open_sidecar_location": self.filesystem.open_sidecar_location,
|
||||
"open_model_sidecar_location": self.filesystem.open_model_sidecar_location,
|
||||
"browse_directory": self.filesystem.browse_directory,
|
||||
"validate_path": self.filesystem.validate_path,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
@@ -4236,6 +4520,8 @@ class MiscHandlerSet:
|
||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||
# Download routing handler
|
||||
"get_download_routing": self.download_routing.get_download_routing,
|
||||
# Sidecar migration handler
|
||||
"migrate_sidecars": self.sidecar_migration.migrate_sidecars,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
|
||||
@@ -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,8 @@ 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
|
||||
|
||||
LICENSE_FIELDS = (
|
||||
@@ -386,7 +392,6 @@ class ModelListingHandler:
|
||||
== "true",
|
||||
"tags": request.query.get("search_tags", "false").lower() == "true",
|
||||
"creator": request.query.get("search_creator", "false").lower() == "true",
|
||||
"hash": request.query.get("search_hash", "false").lower() == "true",
|
||||
"recursive": request.query.get("recursive", "true").lower() == "true",
|
||||
}
|
||||
|
||||
@@ -676,7 +681,7 @@ class ModelManagementHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
local_metadata = await self._metadata_sync.load_local_metadata(
|
||||
metadata_path
|
||||
)
|
||||
@@ -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(
|
||||
{
|
||||
"status": "success",
|
||||
"message": f"{self._service.model_type.capitalize()} scan completed",
|
||||
}
|
||||
)
|
||||
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",
|
||||
@@ -1760,7 +1818,8 @@ class ModelDownloadHandler:
|
||||
payload = await request.json()
|
||||
result = await self._download_use_case.execute(payload)
|
||||
if not result.get("success", False):
|
||||
return web.json_response(result, status=500)
|
||||
status = 429 if result.get("reason") == "rate_limited" else 500
|
||||
return web.json_response(result, status=status)
|
||||
return web.json_response(result)
|
||||
except DownloadModelValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
@@ -1818,7 +1877,8 @@ class ModelDownloadHandler:
|
||||
mock_request = type("MockRequest", (), {"json": lambda self=None: future})()
|
||||
result = await self._download_use_case.execute(data)
|
||||
if not result.get("success", False):
|
||||
return web.json_response(result, status=500)
|
||||
status = 429 if result.get("reason") == "rate_limited" else 500
|
||||
return web.json_response(result, status=status)
|
||||
return web.json_response(result)
|
||||
except DownloadModelValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
@@ -2299,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")
|
||||
|
||||
@@ -2510,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()
|
||||
@@ -2939,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)
|
||||
@@ -2960,6 +3050,7 @@ class ModelUpdateHandler:
|
||||
{
|
||||
"success": True,
|
||||
"records": serialized_records,
|
||||
"events": events,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3389,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
|
||||
@@ -3412,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")
|
||||
)
|
||||
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
|
||||
# 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
|
||||
)
|
||||
elif getattr(version, "is_early_access", False):
|
||||
# Fallback to basic EA flag from bulk API
|
||||
is_early_access = True
|
||||
@@ -3448,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
|
||||
@@ -3591,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,
|
||||
|
||||
@@ -31,7 +31,6 @@ from ...services.model_sources import (
|
||||
detect_source,
|
||||
get_download_source,
|
||||
hydrate_from_source,
|
||||
is_valid_source_id,
|
||||
list_sources,
|
||||
normalize_metadata_source,
|
||||
)
|
||||
@@ -39,7 +38,12 @@ from ...services.settings_manager import get_settings_manager
|
||||
from ...services.service_registry import ServiceRegistry
|
||||
from ...services.websocket_manager import ws_manager
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ...utils.models import (
|
||||
LoraMetadata,
|
||||
CheckpointMetadata,
|
||||
EmbeddingMetadata,
|
||||
OtherModelMetadata,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -78,6 +82,11 @@ def _infer_model_type(model_root: str) -> tuple[Any, str]:
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return EmbeddingMetadata, "get_embedding_scanner"
|
||||
|
||||
# Other-model roots (VAE, text encoders, upscalers, ...)
|
||||
for p in config.other_roots or []:
|
||||
if os.path.normpath(p).replace(os.sep, "/") == norm:
|
||||
return OtherModelMetadata, "get_other_scanner"
|
||||
|
||||
# Fallback — should not happen in normal use
|
||||
logger.warning(
|
||||
"Could not determine model type for root '%s'; defaulting to LoRA",
|
||||
@@ -213,12 +222,14 @@ def _find_matching_root(dest_dir: str) -> str | None:
|
||||
config.extra_unet_roots or [],
|
||||
config.embeddings_roots or [],
|
||||
config.extra_embeddings_roots or [],
|
||||
config.other_roots or [],
|
||||
):
|
||||
all_roots.extend([os.path.normpath(p).replace(os.sep, "/") for p in root_list])
|
||||
# Find the longest matching prefix
|
||||
# Find the longest matching prefix. The boundary check prevents a root like
|
||||
# `/models/vae` from swallowing a sibling directory like `/models/vae-old`.
|
||||
match: str | None = None
|
||||
for root in all_roots:
|
||||
if norm.startswith(root):
|
||||
if norm == root or norm.startswith(root + "/"):
|
||||
if match is None or len(root) > len(match):
|
||||
match = root
|
||||
return match
|
||||
@@ -265,9 +276,7 @@ class ModelSourceHandler:
|
||||
"supports_enrichment": source.supports_enrichment,
|
||||
"supports_download": source.supports_download,
|
||||
"default_revision": source.default_revision,
|
||||
"example_url": source.canonical_url(
|
||||
"user/repo" if source.platform != "tensorart" else "827823520299086029"
|
||||
),
|
||||
"example_url": source.canonical_url(source.example_source_id),
|
||||
}
|
||||
for source in list_sources()
|
||||
])
|
||||
@@ -314,9 +323,7 @@ class ModelSourceHandler:
|
||||
"error": (
|
||||
"Unsupported model URL. Supported formats: "
|
||||
+ ", ".join(
|
||||
f"{s.label} ({s.canonical_url('user/repo')})"
|
||||
if s.platform != "tensorart"
|
||||
else f"{s.label} (https://tensor.art/models/<id>)"
|
||||
f"{s.label} ({s.canonical_url(s.example_source_id)})"
|
||||
for s in list_sources()
|
||||
)
|
||||
),
|
||||
@@ -412,9 +419,9 @@ class ModelSourceHandler:
|
||||
source = get_download_source(platform)
|
||||
if source is None:
|
||||
return _unsupported_platform_error(platform)
|
||||
if not is_valid_source_id(repo):
|
||||
if not source.is_valid_source_id(repo):
|
||||
return web.json_response(
|
||||
{"error": "Missing or invalid 'repo' parameter (expected owner/name)"},
|
||||
{"error": "Missing or invalid 'repo' parameter"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
@@ -480,10 +487,11 @@ class ModelSourceHandler:
|
||||
{"error": "Missing required fields: 'repo' and 'filename'"}, status=400
|
||||
)
|
||||
|
||||
# `owner/name` only; the components become path segments below.
|
||||
if not is_valid_source_id(repo):
|
||||
# The id becomes a path segment below; each site defines what a safe
|
||||
# id looks like (`owner/name` for repository sites, a flat token for
|
||||
# OpenModelDB).
|
||||
if not source.is_valid_source_id(repo):
|
||||
return web.json_response({"error": f"Invalid repo format: {repo}"}, status=400)
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
|
||||
# Validate filename — must not contain path traversal
|
||||
if ".." in filename:
|
||||
@@ -509,7 +517,7 @@ class ModelSourceHandler:
|
||||
base_dir = os.path.normpath(os.path.join(os.getcwd(), "models", model_root))
|
||||
|
||||
if use_default_paths:
|
||||
target_dir = os.path.join(base_dir, source.default_subdir, owner, repo_name)
|
||||
target_dir = os.path.join(base_dir, *source.default_subdir_parts(repo))
|
||||
elif relative_path:
|
||||
target_dir = os.path.join(base_dir, relative_path)
|
||||
else:
|
||||
@@ -524,7 +532,10 @@ class ModelSourceHandler:
|
||||
|
||||
# Built per request: sites that redirect to a CDN hand out a
|
||||
# time-limited token in the redirect, so the URL must never be cached.
|
||||
resolve_url = source.file_download_url(repo, filename, revision)
|
||||
try:
|
||||
resolve_url = await source.resolve_download_url(repo, filename, revision)
|
||||
except ModelSourceError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=exc.status)
|
||||
ref = SourceRef(
|
||||
platform=source.platform, source_id=repo, url=source.canonical_url(repo)
|
||||
)
|
||||
|
||||
@@ -54,7 +54,18 @@ class PreviewHandler:
|
||||
|
||||
if not resolved.is_file():
|
||||
logger.debug("Preview file not found at %s", str(resolved))
|
||||
asyncio.create_task(self._cleanup_stale_preview_url(normalized))
|
||||
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
|
||||
|
||||
@@ -308,6 +308,9 @@ class RecipeListingHandler:
|
||||
if lora_availability:
|
||||
filters["lora_availability"] = lora_availability
|
||||
|
||||
if request.query.get("has_workflow", "").lower() == "true":
|
||||
filters["has_workflow"] = True
|
||||
|
||||
lora_hash = request.query.get("lora_hash")
|
||||
checkpoint_hash = request.query.get("checkpoint_hash")
|
||||
|
||||
@@ -1284,6 +1287,21 @@ class RecipeManagementHandler:
|
||||
_original_image_url,
|
||||
) = await self._download_remote_media(image_url)
|
||||
|
||||
# CivitAI's optimized rendition is re-encoded and metadata-free, so an
|
||||
# embedded ComfyUI workflow only exists in the original. Fetch it
|
||||
# lazily: unlike the URL import path (which needs the original for
|
||||
# metadata parsing anyway), this path would download it purely for the
|
||||
# workflow, so it is skipped unless the API reports one.
|
||||
original_workflow = None
|
||||
if _original_image_url and self._meta_indicates_comfy_workflow(
|
||||
civitai_meta_raw
|
||||
):
|
||||
_raw_original, original_workflow = await self._fetch_original_media(
|
||||
_original_image_url
|
||||
)
|
||||
if original_workflow:
|
||||
metadata["workflow"] = original_workflow
|
||||
|
||||
# Build a version-cached map of local model hashes to cache items so
|
||||
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||
# exist on disk. Built once and shared by every parse pass below.
|
||||
@@ -1806,6 +1824,10 @@ class RecipeManagementHandler:
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
# Opt-in workflow embedding. The widget historically POSTs with no
|
||||
# body at all, so a missing/empty body is not an error.
|
||||
workflow = await self._read_optional_json_field(request, "workflow")
|
||||
|
||||
analysis = await self._analysis_service.analyze_widget_metadata(
|
||||
recipe_scanner=recipe_scanner
|
||||
)
|
||||
@@ -1818,6 +1840,7 @@ class RecipeManagementHandler:
|
||||
recipe_scanner=recipe_scanner,
|
||||
metadata=metadata,
|
||||
image_bytes=image_bytes,
|
||||
workflow=workflow,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
@@ -1882,6 +1905,24 @@ class RecipeManagementHandler:
|
||||
return []
|
||||
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
||||
|
||||
async def _read_optional_json_field(
|
||||
self, request: web.Request, field: str
|
||||
) -> Any:
|
||||
"""Read one field from an optional JSON request body.
|
||||
|
||||
Some callers (notably the widget's long-standing "Save Recipe" action)
|
||||
POST with no body at all, and a stale cached extension may still do so
|
||||
after a body is introduced. A missing, empty or malformed body is
|
||||
therefore treated as "no value" rather than a request error.
|
||||
"""
|
||||
if not request.can_read_body:
|
||||
return None
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return None
|
||||
return data.get(field) if isinstance(data, dict) else None
|
||||
|
||||
async def _count_recipe_loras(
|
||||
self, recipe_scanner: Any, recipe_id: Optional[str]
|
||||
) -> Optional[int]:
|
||||
@@ -2090,6 +2131,90 @@ class RecipeManagementHandler:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def _read_embedded_workflow(self, image_path: Optional[str]) -> Optional[str]:
|
||||
"""Return a ComfyUI workflow embedded in ``image_path``, if any.
|
||||
|
||||
``ExifUtils.extract_image_metadata`` stops at the generation
|
||||
parameters, so the UI-format workflow has to be read through the
|
||||
structured metadata reader. Failures map to ``None``.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return None
|
||||
try:
|
||||
metadata = ExifUtils._load_structured_metadata(image_path)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Failed to read embedded workflow from %s: %s", image_path, exc
|
||||
)
|
||||
return None
|
||||
workflow = metadata.get("workflow") if isinstance(metadata, dict) else None
|
||||
return workflow if isinstance(workflow, str) and workflow else None
|
||||
|
||||
@staticmethod
|
||||
def _meta_indicates_comfy_workflow(civitai_meta_raw: Any) -> bool:
|
||||
"""Whether CivitAI reports an embedded ComfyUI workflow for an image.
|
||||
|
||||
``meta.comfy`` is the payload CivitAI captured from the original image,
|
||||
so its presence is the signal that fetching the original is worth the
|
||||
bandwidth when the caller does not already need it for metadata
|
||||
parsing.
|
||||
"""
|
||||
if not isinstance(civitai_meta_raw, dict):
|
||||
return False
|
||||
inner = civitai_meta_raw.get("meta")
|
||||
if isinstance(inner, dict) and inner.get("comfy"):
|
||||
return True
|
||||
return bool(civitai_meta_raw.get("comfy"))
|
||||
|
||||
async def _fetch_original_media(
|
||||
self, original_image_url: Optional[str]
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Download the original rendition and read its embedded media.
|
||||
|
||||
CivitAI's optimized renditions are re-encoded and carry no metadata, so
|
||||
the original is the only source for embedded generation metadata and
|
||||
for the UI-format ComfyUI workflow (the raw extractor's fallback chain
|
||||
ends at ``workflow`` only when no prompt is present).
|
||||
|
||||
Returns ``(raw_metadata, workflow)``; either element is ``None`` when
|
||||
unavailable. Failures never raise — imports keep working with the
|
||||
optimized rendition when the original cannot be fetched.
|
||||
"""
|
||||
if not original_image_url:
|
||||
return None, None
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
try:
|
||||
downloader = await self._downloader_factory()
|
||||
success, _result = await downloader.download_file(
|
||||
original_image_url, temp_path, use_auth=False
|
||||
)
|
||||
if not success:
|
||||
self._logger.warning(
|
||||
"Failed to download original rendition: %s", original_image_url
|
||||
)
|
||||
return None, None
|
||||
|
||||
raw_metadata = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_path
|
||||
)
|
||||
workflow = await asyncio.to_thread(
|
||||
self._read_embedded_workflow, temp_path
|
||||
)
|
||||
return raw_metadata, workflow
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to read original rendition %s: %s", original_image_url, exc
|
||||
)
|
||||
return None, None
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _safe_int(self, value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
@@ -2295,6 +2420,7 @@ class RecipeManagementHandler:
|
||||
"Failed to extract embedded metadata: %s", exc
|
||||
)
|
||||
|
||||
original_workflow: Optional[str] = None
|
||||
if not parsed_embedded and original_image_url:
|
||||
self._logger.debug(
|
||||
"Optimized image has no embedded metadata, "
|
||||
@@ -2302,48 +2428,32 @@ class RecipeManagementHandler:
|
||||
original_image_url,
|
||||
)
|
||||
try:
|
||||
downloader = await self._downloader_factory()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".png", delete=False
|
||||
) as tmp:
|
||||
orig_tmp_path = tmp.name
|
||||
try:
|
||||
success, _ = await downloader.download_file(
|
||||
original_image_url, orig_tmp_path, use_auth=False
|
||||
)
|
||||
if success:
|
||||
raw_orig = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, orig_tmp_path
|
||||
raw_orig, original_workflow = await self._fetch_original_media(
|
||||
original_image_url
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig) or bool(
|
||||
diagnostics.get("exif_present")
|
||||
)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_orig
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_orig
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
recipe_scanner=recipe_scanner,
|
||||
local_cache=local_cache,
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
recipe_scanner=recipe_scanner,
|
||||
local_cache=local_cache,
|
||||
)
|
||||
else:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if (
|
||||
parsed_embedded
|
||||
and "gen_params" in parsed_embedded
|
||||
):
|
||||
embedded_gen_params = parsed_embedded[
|
||||
"gen_params"
|
||||
]
|
||||
finally:
|
||||
if os.path.exists(orig_tmp_path):
|
||||
os.unlink(orig_tmp_path)
|
||||
else:
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Failed to extract metadata from original image: %s", exc
|
||||
@@ -2391,6 +2501,8 @@ class RecipeManagementHandler:
|
||||
"gen_params": embedded_gen_params or {},
|
||||
"source_path": image_url,
|
||||
}
|
||||
if original_workflow:
|
||||
metadata["workflow"] = original_workflow
|
||||
|
||||
# Extract preview_nsfw_level from the CivitAI API response
|
||||
# (injected into civitai_meta_raw by _download_remote_media).
|
||||
|
||||
@@ -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"),
|
||||
@@ -113,6 +115,19 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download/routing", "get_download_routing"
|
||||
),
|
||||
# Sidecar storage layout migration (GET supported for the extension)
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/migrate", "migrate_sidecars"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/sidecars/migrate", "migrate_sidecars"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/models/open-sidecar-location", "open_model_sidecar_location"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-model-source", "download_model_source"
|
||||
),
|
||||
|
||||
@@ -32,6 +32,7 @@ from .handlers.misc_handlers import (
|
||||
NodeRegistry,
|
||||
NodeRegistryHandler,
|
||||
SettingsHandler,
|
||||
SidecarMigrationHandler,
|
||||
SupportersHandler,
|
||||
TrainedWordsHandler,
|
||||
UsageStatsHandler,
|
||||
@@ -142,6 +143,7 @@ class MiscRoutes:
|
||||
model_source_handler = ModelSourceHandler()
|
||||
agent_handler = AgentHandler()
|
||||
download_routing = DownloadRoutingHandler()
|
||||
sidecar_migration = SidecarMigrationHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -164,6 +166,7 @@ class MiscRoutes:
|
||||
model_source_handler=model_source_handler,
|
||||
agent_handler=agent_handler,
|
||||
download_routing=download_routing,
|
||||
sidecar_migration=sidecar_migration,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,8 @@ import os
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local
|
||||
from .model_scanner import ModelScanner
|
||||
|
||||
@@ -41,7 +43,7 @@ def _resolve_autov3(file_path: str) -> str:
|
||||
safetensors header hash. Returns ``''`` when neither is available.
|
||||
"""
|
||||
try:
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -645,6 +645,11 @@ class BatchImportService:
|
||||
if payload.get("checkpoint"):
|
||||
metadata["checkpoint"] = payload["checkpoint"]
|
||||
|
||||
# A workflow recovered from the source's original rendition
|
||||
# travels as metadata and is embedded into the stored image.
|
||||
if payload.get("workflow"):
|
||||
metadata["workflow"] = payload["workflow"]
|
||||
|
||||
nsfw = payload.get("preview_nsfw_level")
|
||||
if isinstance(nsfw, int) and nsfw > 0:
|
||||
metadata["preview_nsfw_level"] = nsfw
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_preview_dir, is_centralized
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -61,10 +62,9 @@ class CheckpointScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large checkpoints; record the checked state at creation time ("" =
|
||||
@@ -322,6 +322,11 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for checkpoint metadata files with pending hash status."""
|
||||
# Centralized mode stores sidecars in the mirror tree, not next to the
|
||||
# models; walk the mirror instead of the model folders.
|
||||
if is_centralized():
|
||||
return self._find_pending_models_in_sidecar_mirror()
|
||||
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
+204
-34
@@ -33,17 +33,26 @@ 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
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
|
||||
from .service_registry import ServiceRegistry
|
||||
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
from .errors import RateLimitError
|
||||
from .errors import DownloadRateLimitError, RateLimitError
|
||||
from .rate_limit_coordinator import RateLimitCoordinator
|
||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .download_queue_service import DownloadQueueService
|
||||
@@ -59,6 +68,18 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
"https://civitai.red/api/download/",
|
||||
)
|
||||
|
||||
# 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. 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).
|
||||
DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS = 60
|
||||
|
||||
|
||||
# File types that are never the intended download target even when CivitAI
|
||||
# marks them primary — configs/archives/workflows are auxiliary artifacts.
|
||||
@@ -202,11 +223,30 @@ class DownloadManager:
|
||||
)
|
||||
except Aria2Error as exc:
|
||||
logger.error("aria2 download failed for %s: %s", download_url, exc)
|
||||
# Best-effort 429 detection: aria2 reports HTTP status errors
|
||||
# via its error message (e.g. "status=429") without exposing
|
||||
# the vendor's Retry-After. Surface the structured rate-limit
|
||||
# error so the queue contract behaves the same as the python
|
||||
# backend; the coordinator backoff supplies the wait time.
|
||||
message = str(exc)
|
||||
if "429" in message or "rate limit" in message.lower():
|
||||
host = self._url_host(download_url)
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
if coordinator.enabled:
|
||||
coordinator.register_rate_limit(host, None)
|
||||
raise DownloadRateLimitError(
|
||||
f"Download rate limited (429): {message}",
|
||||
retry_after=None,
|
||||
host=host,
|
||||
) from exc
|
||||
return False, str(exc)
|
||||
|
||||
download_kwargs: Dict[str, Any] = {
|
||||
"progress_callback": progress_callback,
|
||||
"use_auth": use_auth,
|
||||
# The model download queue contract requires structured 429
|
||||
# propagation (reason="rate_limited"), not a plain error string.
|
||||
"raise_on_rate_limit": True,
|
||||
}
|
||||
|
||||
if pause_control is not None:
|
||||
@@ -215,6 +255,99 @@ class DownloadManager:
|
||||
downloader = await get_downloader()
|
||||
return await downloader.download_file(download_url, save_path, **download_kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _url_host(url: str) -> str:
|
||||
"""Extract the normalized hostname from a URL (fallback: ``unknown``)."""
|
||||
hostname = urlparse(url).hostname
|
||||
return hostname.lower() if hostname else "unknown"
|
||||
|
||||
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 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 hosts:
|
||||
remaining = coordinator.remaining_seconds(host)
|
||||
if remaining > worst_remaining:
|
||||
worst_host = host
|
||||
worst_remaining = remaining
|
||||
if worst_host is None or worst_remaining <= 0:
|
||||
return None
|
||||
retry_after = max(1, int(worst_remaining + 0.5))
|
||||
return DownloadRateLimitError(
|
||||
f"Download rate limited: '{worst_host}' is in cooldown, "
|
||||
f"retry after {retry_after}s",
|
||||
retry_after=worst_remaining,
|
||||
host=worst_host,
|
||||
)
|
||||
|
||||
async def _handle_rate_limited_download(
|
||||
self,
|
||||
task_id: str,
|
||||
exc: RateLimitError,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the structured rate-limit result for a failed download.
|
||||
|
||||
The queue row goes back to ``queued`` (NOT history) so a later retry
|
||||
simply starts the download again — this is what lets the companion
|
||||
extension auto-pause the queue during a 429 episode and resume it
|
||||
after ``retry_after`` seconds.
|
||||
"""
|
||||
retry_after = exc.retry_after
|
||||
host = getattr(exc, "host", None) or exc.provider
|
||||
if retry_after is None or retry_after <= 0:
|
||||
# The coordinator clamps/backoffs via register_rate_limit, so its
|
||||
# remaining cooldown supplies the number when the vendor didn't.
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
remaining = coordinator.remaining_seconds(host)
|
||||
if remaining > 0:
|
||||
retry_after = remaining
|
||||
if retry_after is None or retry_after <= 0:
|
||||
retry_after = float(DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS)
|
||||
retry_after_seconds = max(1, int(retry_after + 0.5))
|
||||
|
||||
message = str(exc) or (
|
||||
f"Download rate limited, retry after {retry_after_seconds}s"
|
||||
)
|
||||
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]["status"] = "queued"
|
||||
self._active_downloads[task_id]["error"] = message
|
||||
self._active_downloads[task_id]["bytes_per_second"] = 0.0
|
||||
|
||||
try:
|
||||
queue_service = await DownloadQueueService.get_instance()
|
||||
await queue_service.update_status(task_id, "queued", error=message)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to re-queue rate-limited download %s", task_id, exc_info=True
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"reason": "rate_limited",
|
||||
"retry_after": retry_after_seconds,
|
||||
"error": message,
|
||||
}
|
||||
|
||||
async def _get_lora_scanner(self):
|
||||
"""Get the lora scanner from registry"""
|
||||
return await ServiceRegistry.get_lora_scanner()
|
||||
@@ -557,6 +690,15 @@ class DownloadManager:
|
||||
original_callback, snapshot, progress_value
|
||||
)
|
||||
|
||||
# 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(source)
|
||||
if preflight_error is not None:
|
||||
logger.info(
|
||||
"Download %s skipped: %s", task_id, preflight_error
|
||||
)
|
||||
return await self._handle_rate_limited_download(task_id, preflight_error)
|
||||
|
||||
# Acquire semaphore to limit concurrent downloads
|
||||
try:
|
||||
async with self._download_semaphore:
|
||||
@@ -661,6 +803,12 @@ class DownloadManager:
|
||||
|
||||
logger.info(f"Download cancelled for task {task_id}")
|
||||
raise
|
||||
except RateLimitError as e:
|
||||
# 429 (real vendor response or cooldown gate): re-queue
|
||||
# instead of completing as failed so a later retry just
|
||||
# starts the download again.
|
||||
logger.info(f"Download rate limited for task {task_id}: {e}")
|
||||
return await self._handle_rate_limited_download(task_id, e)
|
||||
except Exception as e:
|
||||
# Handle other errors
|
||||
logger.error(
|
||||
@@ -831,7 +979,7 @@ class DownloadManager:
|
||||
)
|
||||
|
||||
for file_path in target_files:
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
deleted = await self._delete_file_with_retries(metadata_path)
|
||||
if not deleted and os.path.exists(metadata_path):
|
||||
logger.error(f"Error deleting metadata file: {metadata_path}")
|
||||
@@ -1522,7 +1670,11 @@ class DownloadManager:
|
||||
return {"success": False, "error": "Failed to fetch model metadata"}
|
||||
|
||||
model_type_from_info = version_info.get("model", {}).get("type", "").lower()
|
||||
if model_type_from_info == "checkpoint":
|
||||
# CivitAI ModelType has no model-level diffusion variant: DiT
|
||||
# models are uploaded as "Checkpoint" or "UNet". Both go through
|
||||
# the checkpoint branch so the standard diffusion routing chain
|
||||
# (file type -> baseModel lists -> unknown default) applies.
|
||||
if model_type_from_info in ("checkpoint", "unet"):
|
||||
model_type = "checkpoint"
|
||||
elif model_type_from_info in VALID_LORA_TYPES:
|
||||
model_type = "lora"
|
||||
@@ -1669,6 +1821,9 @@ class DownloadManager:
|
||||
model_type,
|
||||
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
||||
base_model=base_model_value,
|
||||
unknown_base_model_default=get_settings_manager().get(
|
||||
"unknown_base_model_routing", "diffusion_model"
|
||||
),
|
||||
)
|
||||
|
||||
# Existence check after the metadata fetch (#1058):
|
||||
@@ -1851,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}). "
|
||||
)
|
||||
@@ -2107,6 +2254,10 @@ class DownloadManager:
|
||||
|
||||
return result
|
||||
|
||||
except RateLimitError:
|
||||
# Structured 429 propagation must reach _download_with_semaphore
|
||||
# unmodified so the queue row is re-queued instead of failed.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download_from_civitai: {e}", exc_info=True)
|
||||
# Check if this might be an early access error
|
||||
@@ -2447,7 +2598,7 @@ class DownloadManager:
|
||||
return {"success": False, "error": save_path}
|
||||
|
||||
part_path = save_path + ".part"
|
||||
metadata_path = os.path.splitext(save_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(save_path)
|
||||
|
||||
pause_control = self._pause_events.get(download_id) if download_id else None
|
||||
|
||||
@@ -2464,6 +2615,10 @@ class DownloadManager:
|
||||
# Download preview image if available
|
||||
images = version_info.get("images", [])
|
||||
if images:
|
||||
# Centralized preview mirrors may not exist yet (unlike the
|
||||
# model's own directory in alongside mode).
|
||||
os.makedirs(get_preview_dir(save_path), exist_ok=True)
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
1
|
||||
@@ -2503,7 +2658,10 @@ class DownloadManager:
|
||||
|
||||
if media_type == "video":
|
||||
preview_ext = _extension_from_url(preview_url, ".mp4")
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
|
||||
)
|
||||
rewritten_url, rewritten = rewrite_preview_url(
|
||||
preview_url, media_type="video"
|
||||
)
|
||||
@@ -2530,7 +2688,10 @@ class DownloadManager:
|
||||
)
|
||||
if rewritten and rewritten_url:
|
||||
preview_ext = _extension_from_url(preview_url, ".png")
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + preview_ext,
|
||||
)
|
||||
success, _ = await downloader.download_file(
|
||||
rewritten_url, preview_path, use_auth=False
|
||||
)
|
||||
@@ -2557,8 +2718,9 @@ class DownloadManager:
|
||||
temp_file_handle.write(
|
||||
content if isinstance(content, bytes) else content.encode("utf-8")
|
||||
)
|
||||
preview_path = (
|
||||
os.path.splitext(save_path)[0] + ".webp"
|
||||
preview_path = os.path.join(
|
||||
get_preview_dir(save_path),
|
||||
os.path.splitext(os.path.basename(save_path))[0] + ".webp",
|
||||
)
|
||||
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
@@ -2788,9 +2950,7 @@ class DownloadManager:
|
||||
entry = cast(Any, adjusted_entry)
|
||||
metadata_entries[index] = entry
|
||||
|
||||
metadata_file_path = (
|
||||
os.path.splitext(entry.file_path)[0] + ".metadata.json"
|
||||
)
|
||||
metadata_file_path = get_metadata_path(entry.file_path)
|
||||
metadata_files_for_cleanup.append(metadata_file_path)
|
||||
|
||||
await MetadataManager.save_metadata(entry.file_path, entry)
|
||||
@@ -2820,6 +2980,10 @@ class DownloadManager:
|
||||
|
||||
return {"success": True}
|
||||
|
||||
except RateLimitError:
|
||||
# Structured 429 propagation must reach _download_with_semaphore
|
||||
# unmodified so the queue row is re-queued instead of failed.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error in _execute_download: {e}", exc_info=True)
|
||||
cleanup_targets = {
|
||||
@@ -3049,7 +3213,11 @@ class DownloadManager:
|
||||
extension = os.path.splitext(preview_path)[1] or ".webp"
|
||||
|
||||
targets = [
|
||||
os.path.splitext(entry.file_path)[0] + extension for entry in entries
|
||||
os.path.join(
|
||||
get_preview_dir(entry.file_path),
|
||||
os.path.splitext(os.path.basename(entry.file_path))[0] + extension,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
if not targets:
|
||||
@@ -3057,10 +3225,12 @@ class DownloadManager:
|
||||
|
||||
first_target = targets[0]
|
||||
if preview_path != first_target:
|
||||
os.makedirs(os.path.dirname(first_target), exist_ok=True)
|
||||
os.replace(preview_path, first_target)
|
||||
source_path = first_target
|
||||
|
||||
for target in targets[1:]:
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
shutil.copyfile(source_path, target)
|
||||
|
||||
return targets
|
||||
|
||||
@@ -13,6 +13,7 @@ import logging
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from ..utils.constants import (
|
||||
CHECKPOINT_BASE_MODELS,
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
@@ -24,17 +25,27 @@ logger = logging.getLogger(__name__)
|
||||
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
|
||||
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
|
||||
|
||||
# Allowed values for the "unknown_base_model_routing" setting / the
|
||||
# unknown_base_model_default parameter below.
|
||||
ROUTING_DIFFUSION_MODEL = "diffusion_model"
|
||||
ROUTING_CHECKPOINT = "checkpoint"
|
||||
|
||||
|
||||
def is_diffusion_model_download(
|
||||
model_type: str,
|
||||
file_types: Iterable[str] = (),
|
||||
base_model: str = "",
|
||||
unknown_base_model_default: str = ROUTING_DIFFUSION_MODEL,
|
||||
) -> bool:
|
||||
"""Return True when a download should be routed to the unet roots.
|
||||
|
||||
Only applies to downloads initiated from the checkpoint library.
|
||||
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
|
||||
direct signal from CivitAI), (2) baseModel is a known diffusion model.
|
||||
direct signal from CivitAI), (2) baseModel is a known diffusion model,
|
||||
(3) baseModel is a known full checkpoint -> not diffusion, (4) unknown
|
||||
or empty baseModel -> the ``unknown_base_model_default`` setting, which
|
||||
defaults to diffusion because the set of true checkpoint families is
|
||||
closed while new DiT base models appear all the time.
|
||||
"""
|
||||
if model_type != "checkpoint":
|
||||
return False
|
||||
@@ -54,7 +65,16 @@ def is_diffusion_model_download(
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
if base_model in CHECKPOINT_BASE_MODELS:
|
||||
return False
|
||||
|
||||
is_diffusion = unknown_base_model_default != ROUTING_CHECKPOINT
|
||||
logger.info(
|
||||
"baseModel '%s' is unknown, routing to %s folder (unknown_base_model_routing)",
|
||||
base_model,
|
||||
"unet" if is_diffusion else "checkpoint",
|
||||
)
|
||||
return is_diffusion
|
||||
|
||||
|
||||
def resolve_other_download_sub_type(
|
||||
|
||||
@@ -31,7 +31,7 @@ from .connectivity_guard import (
|
||||
OFFLINE_FRIENDLY_MESSAGE,
|
||||
ConnectivityGuard,
|
||||
)
|
||||
from .errors import RateLimitError
|
||||
from .errors import DownloadRateLimitError, RateLimitError
|
||||
from .rate_limit_coordinator import RateLimitCoordinator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -434,6 +434,7 @@ class Downloader:
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
allow_resume: bool = True,
|
||||
pause_event: Optional[DownloadStreamControl] = None,
|
||||
raise_on_rate_limit: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Download a file with resumable downloads and retry mechanism
|
||||
@@ -446,6 +447,11 @@ class Downloader:
|
||||
custom_headers: Additional headers to include in request
|
||||
allow_resume: Whether to support resumable downloads
|
||||
pause_event: Optional stream control used to pause/resume and request reconnects
|
||||
raise_on_rate_limit: When True, a 429 response raises
|
||||
``DownloadRateLimitError`` instead of returning a plain error
|
||||
string, so callers (the model download manager) can build the
|
||||
structured rate-limit result required by the download queue
|
||||
contract. Defaults to the legacy tuple behavior.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, save_path or error message)
|
||||
@@ -610,6 +616,12 @@ class Downloader:
|
||||
logger.warning(
|
||||
f"Rate limited (429) for {url}, retry_after={retry_after}"
|
||||
)
|
||||
if raise_on_rate_limit:
|
||||
raise DownloadRateLimitError(
|
||||
f"Download rate limited (429), retry after {retry_after}s",
|
||||
retry_after=retry_after,
|
||||
host=self._guard_destination(url),
|
||||
)
|
||||
return False, f"Download rate limited (429), retry after {retry_after}s"
|
||||
else:
|
||||
logger.error(
|
||||
@@ -902,6 +914,11 @@ class Downloader:
|
||||
f"Network error after {self.max_retries + 1} attempts: {str(e)}",
|
||||
)
|
||||
|
||||
except DownloadRateLimitError:
|
||||
# 429s are never retried in-band; the structured error must
|
||||
# reach the caller (download manager) unmodified.
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected download error: {e}")
|
||||
return False, str(e)
|
||||
@@ -931,6 +948,7 @@ class Downloader:
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
return_headers: bool = False,
|
||||
raise_on_rate_limit: bool = False,
|
||||
) -> Tuple[bool, Union[bytes, str], Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Download a file to memory (for small files like preview images)
|
||||
@@ -940,6 +958,10 @@ class Downloader:
|
||||
use_auth: Whether to include authentication headers
|
||||
custom_headers: Additional headers to include in request
|
||||
return_headers: Whether to return response headers along with content
|
||||
raise_on_rate_limit: When True, a 429 response raises
|
||||
``DownloadRateLimitError`` instead of returning a plain error
|
||||
string (see ``download_file``). Defaults to the legacy tuple
|
||||
behavior.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
|
||||
@@ -1002,11 +1024,21 @@ class Downloader:
|
||||
"Rate limited (429) for %s, no Retry-After header; defaulting to %ss",
|
||||
url, retry_after,
|
||||
)
|
||||
if raise_on_rate_limit:
|
||||
raise DownloadRateLimitError(
|
||||
f"Rate limited (429), retry after {retry_after}s",
|
||||
retry_after=retry_after,
|
||||
host=destination,
|
||||
)
|
||||
return False, f"Rate limited (429), retry after {retry_after}s", None
|
||||
else:
|
||||
error_msg = f"Download failed with status {response.status}"
|
||||
return False, error_msg, None
|
||||
|
||||
except DownloadRateLimitError:
|
||||
# Structured rate-limit errors must reach the caller unmodified.
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if guard.is_network_unreachable_error(e):
|
||||
guard.register_network_failure(e, destination)
|
||||
|
||||
@@ -20,12 +20,37 @@ class RateLimitError(RuntimeError):
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class DownloadRateLimitError(RateLimitError):
|
||||
"""Raised when a file download is rejected with HTTP 429.
|
||||
|
||||
Carries the vendor's ``Retry-After`` hint (when present) and the target
|
||||
host so the download manager can build the structured rate-limit result
|
||||
the download queue contract expects.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
retry_after: Optional[float] = None,
|
||||
host: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message, retry_after=retry_after)
|
||||
self.host = host
|
||||
|
||||
|
||||
class ResourceNotFoundError(RuntimeError):
|
||||
"""Raised when a remote resource is permanently missing."""
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .model_metadata_provider import (
|
||||
SQLiteModelMetadataProvider,
|
||||
CivitaiModelMetadataProvider,
|
||||
CivArchiveModelMetadataProvider,
|
||||
OpenModelDBModelMetadataProvider,
|
||||
FallbackMetadataProvider,
|
||||
RateLimitRetryingProvider,
|
||||
)
|
||||
@@ -22,12 +23,18 @@ logger = logging.getLogger(__name__)
|
||||
_PROVIDER_DISPLAY_NAMES = {
|
||||
"civitai_api": "CivitAI",
|
||||
"civarchive_api": "CivArchive",
|
||||
"openmodeldb_api": "OpenModelDB",
|
||||
"sqlite": "Archive DB",
|
||||
}
|
||||
|
||||
# Preset fallback chains. civitai_api is always first (richest metadata).
|
||||
# openmodeldb_api sits right after it: its lookups are local index hits over a
|
||||
# cached bulk dump (no rate-limit budget spent), and it covers upscalers that
|
||||
# CivArchive only has when they once existed on CivitAI. Providers that are not
|
||||
# registered (disabled/unavailable) are skipped, so presets degrade gracefully.
|
||||
_PRESET_PROVIDER_ORDERS = {
|
||||
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
|
||||
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
|
||||
"civitai_archive_sqlite": ["civitai_api", "openmodeldb_api", "civarchive_api", "sqlite"],
|
||||
"civitai_sqlite_archive": ["civitai_api", "openmodeldb_api", "sqlite", "civarchive_api"],
|
||||
}
|
||||
|
||||
async def initialize_metadata_providers():
|
||||
@@ -42,6 +49,7 @@ async def initialize_metadata_providers():
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
|
||||
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||
|
||||
providers = []
|
||||
@@ -92,6 +100,22 @@ async def initialize_metadata_providers():
|
||||
else:
|
||||
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
|
||||
|
||||
# Register the OpenModelDB provider when enabled. It only covers upscaler
|
||||
# models (hash-matched against its catalogue dump), so it complements
|
||||
# rather than replaces the CivitAI-family providers; disabling it avoids
|
||||
# the one-time bulk dump download entirely.
|
||||
if enable_openmodeldb_api:
|
||||
try:
|
||||
openmodeldb_client = await ServiceRegistry.get_openmodeldb_client()
|
||||
openmodeldb_provider = OpenModelDBModelMetadataProvider(openmodeldb_client)
|
||||
provider_manager.register_provider('openmodeldb_api', openmodeldb_provider)
|
||||
providers.append(('openmodeldb_api', openmodeldb_provider))
|
||||
logger.debug("OpenModelDB metadata provider registered (also included in fallback)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize OpenModelDB metadata provider: {e}")
|
||||
else:
|
||||
logger.debug("OpenModelDB metadata provider disabled by setting 'enable_openmodeldb_api'")
|
||||
|
||||
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
|
||||
# civitai_api is always first (better metadata); the remaining providers
|
||||
# are arranged by the configured preset. Providers that are not
|
||||
@@ -135,6 +159,7 @@ async def update_metadata_providers():
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
|
||||
enable_openmodeldb_api = settings_manager.get('enable_openmodeldb_api', True)
|
||||
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
|
||||
|
||||
# Reinitialize all providers with new settings
|
||||
@@ -153,9 +178,10 @@ async def update_metadata_providers():
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
|
||||
"Updated metadata providers: archive_db=%s, civarchive_api=%s, openmodeldb_api=%s, chain=%s",
|
||||
enable_archive_db,
|
||||
enable_civarchive_api,
|
||||
enable_openmodeldb_api,
|
||||
chain,
|
||||
)
|
||||
return provider_manager
|
||||
|
||||
@@ -12,13 +12,51 @@ from ..services.settings_manager import SettingsManager
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
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 .model_sources import has_external_source
|
||||
from .errors import MetadataPersistError, RateLimitError
|
||||
from .model_metadata_provider import _LOCAL_PROVIDER_LABELS
|
||||
from .model_sources import get_source_platform, has_external_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Providers restricted to specific model sub_types, keyed by their
|
||||
# registration label. Providers not listed apply to every model type.
|
||||
# OpenModelDB only indexes upscalers, so it is never consulted for other model
|
||||
# types — that keeps its one-time bulk-catalogue download from being paid by
|
||||
# users who manage no upscalers at all.
|
||||
_PROVIDER_SUB_TYPE_RESTRICTIONS: Dict[str, frozenset] = {
|
||||
"openmodeldb_api": frozenset({"upscaler"}),
|
||||
}
|
||||
|
||||
#: External-source platforms that have their own hash-lookup metadata
|
||||
#: provider. A model downloaded from one of these is refreshed against the
|
||||
#: source's own catalogue first (upgrading the download-time card to the full
|
||||
#: payload) before CivitAI is consulted at all.
|
||||
_EXTERNAL_SOURCE_METADATA_PROVIDERS: Dict[str, str] = {
|
||||
"openmodeldb": "openmodeldb_api",
|
||||
}
|
||||
|
||||
|
||||
def _restricted_providers_for_sub_type(sub_type: Optional[str]) -> list:
|
||||
"""Return the restricted provider labels that apply to ``sub_type``."""
|
||||
return [
|
||||
name
|
||||
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
|
||||
if sub_type in allowed
|
||||
]
|
||||
|
||||
|
||||
def _inapplicable_providers_for_sub_type(sub_type: Optional[str]) -> set:
|
||||
"""Return the restricted provider labels that do NOT apply to ``sub_type``."""
|
||||
return {
|
||||
name
|
||||
for name, allowed in _PROVIDER_SUB_TYPE_RESTRICTIONS.items()
|
||||
if sub_type not in allowed
|
||||
}
|
||||
|
||||
|
||||
def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]:
|
||||
"""Concatenate two word lists, dropping duplicates without reordering.
|
||||
|
||||
@@ -193,9 +231,51 @@ 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}"
|
||||
)
|
||||
self._unblock_example_image_failures(local_metadata)
|
||||
return local_metadata
|
||||
|
||||
@staticmethod
|
||||
def _unblock_example_image_failures(local_metadata: Dict[str, Any]) -> None:
|
||||
"""Drop the model from the example-images failed list, if present.
|
||||
|
||||
Fresh provider metadata invalidates any earlier "no example images"
|
||||
verdict, so the next example-image run should retry the model. The
|
||||
unblock only edits the local progress file — no provider API calls —
|
||||
and must never break the metadata update itself.
|
||||
"""
|
||||
|
||||
sha256 = (local_metadata.get("sha256") or "").lower()
|
||||
if not sha256:
|
||||
return
|
||||
|
||||
try:
|
||||
# Lazy import: example_images_download_manager (via
|
||||
# example_images_metadata) imports this service at module level.
|
||||
from ..utils.example_images_download_manager import (
|
||||
unblock_failed_example_image_models,
|
||||
)
|
||||
|
||||
removed = unblock_failed_example_image_models([sha256])
|
||||
if removed:
|
||||
logger.info(
|
||||
"Re-enabled example image downloads for %s after metadata update",
|
||||
local_metadata.get("model_name", sha256),
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug(
|
||||
"Failed to unblock example image failures for %s: %s", sha256, exc
|
||||
)
|
||||
|
||||
async def fetch_and_update_model(
|
||||
self,
|
||||
*,
|
||||
@@ -216,7 +296,7 @@ class MetadataSyncService:
|
||||
logger.error(error)
|
||||
return False, error
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
enable_archive = self._settings.get("enable_metadata_archive_db", False)
|
||||
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
|
||||
|
||||
@@ -225,6 +305,18 @@ class MetadataSyncService:
|
||||
sqlite_attempted = False
|
||||
|
||||
if model_data.get("civitai_deleted") is True:
|
||||
# Sub_type-restricted providers (e.g. OpenModelDB for
|
||||
# upscalers) stay reachable for deleted models: their
|
||||
# catalogues grow independently of CivitAI, so a model deleted
|
||||
# from CivitAI may still gain metadata there later.
|
||||
for restricted_name in _restricted_providers_for_sub_type(
|
||||
model_data.get("sub_type")
|
||||
):
|
||||
try:
|
||||
provider_attempts.append((restricted_name, await self._get_provider(restricted_name)))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve %s provider: %s", restricted_name, exc)
|
||||
|
||||
if previous_source in (None, "civarchive"):
|
||||
try:
|
||||
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
|
||||
@@ -249,19 +341,44 @@ class MetadataSyncService:
|
||||
is_hf_source = has_external_source(model_data)
|
||||
if is_hf_source:
|
||||
# External-source model (Hugging Face / ModelScope /
|
||||
# TensorArt): only check CivitAI API directly.
|
||||
# CivArchive is almost guaranteed to have no record, and
|
||||
# hitting it wastes rate-limit budget.
|
||||
# TensorArt / OpenModelDB): a source with its own
|
||||
# hash-lookup provider (OpenModelDB) is consulted first,
|
||||
# then CivitAI API directly. CivArchive is almost
|
||||
# guaranteed to have no record, and hitting it wastes
|
||||
# rate-limit budget.
|
||||
# Use a distinct provider name ("civitai_api" not None) so
|
||||
# downstream code does NOT interpret a "Model not found"
|
||||
# response as civitai_api_not_found — which would mark the
|
||||
# model civitai_deleted=True when it was never on CivitAI.
|
||||
try:
|
||||
provider_attempts.append(("civitai_api", await self._get_provider("civitai_api")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve civitai_api provider: %s", exc)
|
||||
source_provider = _EXTERNAL_SOURCE_METADATA_PROVIDERS.get(
|
||||
get_source_platform(model_data)
|
||||
)
|
||||
provider_names = (
|
||||
[source_provider, "civitai_api"]
|
||||
if source_provider
|
||||
else ["civitai_api"]
|
||||
)
|
||||
for provider_name in provider_names:
|
||||
try:
|
||||
provider_attempts.append(
|
||||
(provider_name, await self._get_provider(provider_name))
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug(
|
||||
"Unable to resolve %s provider: %s", provider_name, exc
|
||||
)
|
||||
if not provider_attempts:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
default_provider = await self._get_default_provider()
|
||||
# Drop sub_type-restricted providers that cannot apply to
|
||||
# this model (e.g. OpenModelDB only indexes upscalers), so
|
||||
# their cold-start cost is never paid pointlessly.
|
||||
inapplicable = _inapplicable_providers_for_sub_type(
|
||||
model_data.get("sub_type")
|
||||
)
|
||||
excluding = getattr(default_provider, "excluding", None)
|
||||
if inapplicable and callable(excluding):
|
||||
default_provider = excluding(inapplicable)
|
||||
provider_attempts.append((None, default_provider))
|
||||
|
||||
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||
@@ -272,10 +389,11 @@ class MetadataSyncService:
|
||||
|
||||
skip_network_providers = False
|
||||
for provider_name, provider in provider_attempts:
|
||||
if skip_network_providers and provider_name != "sqlite":
|
||||
if skip_network_providers and provider_name not in _LOCAL_PROVIDER_LABELS:
|
||||
# A network provider was already rate-limited; failing
|
||||
# over to another network provider just spreads the flood
|
||||
# (#1085). The local sqlite archive stays as last resort.
|
||||
# (#1085). Local lookups (sqlite archive, the cached
|
||||
# OpenModelDB index) stay available as a last resort.
|
||||
continue
|
||||
try:
|
||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||
@@ -385,6 +503,7 @@ class MetadataSyncService:
|
||||
readable_source = {
|
||||
"civitai_api": "CivitAI API",
|
||||
"civarchive": "CivArchive API",
|
||||
"openmodeldb": "OpenModelDB",
|
||||
"archive_db": "Archive Database",
|
||||
}.get(source, source)
|
||||
|
||||
@@ -485,7 +604,7 @@ class MetadataSyncService:
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
await self.update_model_metadata(
|
||||
metadata_path,
|
||||
metadata,
|
||||
@@ -505,7 +624,7 @@ class MetadataSyncService:
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply metadata updates and persist to disk and cache."""
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
for key, value in updates.items():
|
||||
@@ -554,7 +673,7 @@ class MetadataSyncService:
|
||||
}
|
||||
|
||||
expected_hash: Optional[str] = None
|
||||
first_metadata_path = os.path.splitext(file_paths[0])[0] + ".metadata.json"
|
||||
first_metadata_path = get_metadata_path(file_paths[0])
|
||||
first_metadata = await metadata_loader(first_metadata_path)
|
||||
if first_metadata and "sha256" in first_metadata:
|
||||
expected_hash = first_metadata["sha256"].lower()
|
||||
@@ -565,7 +684,7 @@ class MetadataSyncService:
|
||||
|
||||
try:
|
||||
actual_hash = await hash_calculator(path)
|
||||
metadata_path = os.path.splitext(path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
stored_hash = metadata.get("sha256", "").lower()
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ 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
|
||||
from ..services.pending_delete_service import PENDING_DELETE_DIR_NAME
|
||||
@@ -15,6 +17,43 @@ 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.
|
||||
|
||||
Business paths only — symlinks are never resolved here, matching how the
|
||||
scanner stores ``excluded_models``. The forward-slash form keeps Windows
|
||||
comparisons working with the scanner's normalized entries.
|
||||
"""
|
||||
return os.path.normpath(os.path.abspath(str(path))).replace(os.sep, "/")
|
||||
|
||||
|
||||
class ProgressCallback(ABC):
|
||||
"""Abstract callback interface for progress reporting"""
|
||||
|
||||
@@ -550,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.
|
||||
|
||||
@@ -567,8 +664,9 @@ class ModelMoveService:
|
||||
|
||||
Returns:
|
||||
Dictionary with the success flag plus a removal manifest
|
||||
(``model_count``/``file_count``/``dir_count``/``symlink_count``/
|
||||
``total_bytes``/``restorable``) on success.
|
||||
(``model_count``/``excluded_model_count``/``file_count``/
|
||||
``dir_count``/``symlink_count``/``total_bytes``/``restorable``)
|
||||
on success.
|
||||
"""
|
||||
try:
|
||||
if not folder_path or not str(folder_path).strip():
|
||||
@@ -586,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 {
|
||||
@@ -594,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"]:
|
||||
@@ -608,13 +725,34 @@ class ModelMoveService:
|
||||
}
|
||||
|
||||
if manifest["model_count"] > 0:
|
||||
model_count = manifest["model_count"]
|
||||
excluded_count = manifest["excluded_model_count"]
|
||||
# Excluded models are hidden from the library lists but are
|
||||
# still real weight files, so they block the cascade just like
|
||||
# any other model. Naming them is what makes the refusal
|
||||
# actionable: the folder looks empty in the sidebar precisely
|
||||
# because everything in it is excluded.
|
||||
if excluded_count == model_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
"all excluded from the library; un-exclude or delete "
|
||||
"them first"
|
||||
)
|
||||
elif excluded_count:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model file(s), "
|
||||
f"{excluded_count} of them excluded from the library; "
|
||||
"delete or move them first"
|
||||
)
|
||||
else:
|
||||
error = (
|
||||
f"Folder still contains {model_count} model "
|
||||
"file(s); delete or move them first"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"code": "not_empty",
|
||||
"error": (
|
||||
f"Folder still contains {manifest['model_count']} model "
|
||||
"file(s); delete or move them first"
|
||||
),
|
||||
"error": error,
|
||||
"manifest": manifest,
|
||||
}
|
||||
|
||||
@@ -631,7 +769,22 @@ class ModelMoveService:
|
||||
|
||||
shutil.rmtree(absolute_path)
|
||||
|
||||
await self._forget_folder(relative_folder)
|
||||
# Centralized mode: prune the folder's mirror subtree when it no
|
||||
# longer holds any sidecar files (per-model deletes already
|
||||
# removed their sidecars, so only empty directories are expected;
|
||||
# a non-empty mirror keeps its orphan sidecars).
|
||||
if is_centralized():
|
||||
mirror_dir = resolve_centralized_dir_for_dir(absolute_path)
|
||||
if mirror_dir and os.path.isdir(mirror_dir):
|
||||
for root, _dirs, files in os.walk(mirror_dir, topdown=False):
|
||||
if files:
|
||||
continue
|
||||
try:
|
||||
os.rmdir(root)
|
||||
except OSError: # pragma: no cover - best-effort cleanup
|
||||
pass
|
||||
|
||||
await self._forget_folder(relative_folder, absolute_path)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -667,13 +820,20 @@ class ModelMoveService:
|
||||
Symbolic links are never followed (``os.walk`` default) and are counted
|
||||
separately — ``shutil.rmtree`` unlinks them without touching their
|
||||
targets.
|
||||
|
||||
``excluded_model_count`` splits the subset of ``model_count`` that the
|
||||
library hides behind the ``exclude`` flag: those files still block the
|
||||
delete, yet they are invisible to the model lists (and therefore to the
|
||||
folder tree, which derives "empty" from them).
|
||||
"""
|
||||
model_count = 0
|
||||
excluded_model_count = 0
|
||||
file_count = 0
|
||||
dir_count = 0
|
||||
symlink_count = 0
|
||||
total_bytes = 0
|
||||
pending_delete_job = False
|
||||
excluded_paths = self._excluded_model_paths()
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(absolute_path):
|
||||
if PENDING_DELETE_DIR_NAME in dirnames:
|
||||
@@ -692,6 +852,8 @@ class ModelMoveService:
|
||||
continue
|
||||
if self._is_model_file(name):
|
||||
model_count += 1
|
||||
if _normalize_match_path(full_path) in excluded_paths:
|
||||
excluded_model_count += 1
|
||||
else:
|
||||
file_count += 1
|
||||
try:
|
||||
@@ -701,6 +863,7 @@ class ModelMoveService:
|
||||
|
||||
return {
|
||||
"model_count": model_count,
|
||||
"excluded_model_count": excluded_model_count,
|
||||
"file_count": file_count,
|
||||
"dir_count": dir_count,
|
||||
"symlink_count": symlink_count,
|
||||
@@ -716,13 +879,35 @@ class ModelMoveService:
|
||||
),
|
||||
}
|
||||
|
||||
async def _forget_folder(self, relative_folder: str) -> None:
|
||||
"""Drop a removed directory from the scanner's folder/cache records."""
|
||||
def _excluded_model_paths(self) -> Set[str]:
|
||||
"""Absolute paths of the models the library hides behind ``exclude``.
|
||||
|
||||
Best-effort: scanner stand-ins that do not expose the accessor simply
|
||||
report no excluded models.
|
||||
"""
|
||||
get_excluded = getattr(self.scanner, "get_excluded_models", None)
|
||||
if not callable(get_excluded):
|
||||
return set()
|
||||
try:
|
||||
paths = get_excluded() or []
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return set()
|
||||
return {_normalize_match_path(path) for path in paths if path}
|
||||
|
||||
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.
|
||||
@@ -763,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 {
|
||||
@@ -771,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)
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.pending_delete_service import get_pending_delete_service
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir, get_sidecar_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,16 +44,22 @@ async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
|
||||
async def delete_model_artifacts(
|
||||
target_dir: str, file_name: str, main_extension: str | None = None
|
||||
) -> List[str]:
|
||||
"""Delete the primary model artefacts within ``target_dir``."""
|
||||
"""Delete the primary model artefacts within ``target_dir``.
|
||||
|
||||
Sidecars and previews are taken from the model's sidecar directory — the
|
||||
model's own directory in alongside mode, the centralized mirror otherwise.
|
||||
"""
|
||||
|
||||
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||
model_path = os.path.join(target_dir, main_file)
|
||||
sidecar_dir = get_sidecar_dir(model_path)
|
||||
patterns = [os.path.basename(get_metadata_path(model_path))]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{file_name}{ext}")
|
||||
|
||||
deleted: List[str] = []
|
||||
main_path = os.path.join(target_dir, main_file).replace(os.sep, "/")
|
||||
main_path = model_path.replace(os.sep, "/")
|
||||
|
||||
if os.path.exists(main_path):
|
||||
os.remove(main_path)
|
||||
@@ -58,8 +67,8 @@ async def delete_model_artifacts(
|
||||
else:
|
||||
logger.warning("Model file not found: %s", main_file)
|
||||
|
||||
for pattern in patterns[1:]:
|
||||
path = os.path.join(target_dir, pattern)
|
||||
for pattern in patterns:
|
||||
path = os.path.join(sidecar_dir, pattern)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@@ -100,6 +109,36 @@ def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "pat
|
||||
)
|
||||
|
||||
|
||||
class BulkRenameContext:
|
||||
"""Per-session state threaded through ``rename_model`` calls of a bulk rename.
|
||||
|
||||
Holds the lazily built recipe hash index so a bulk rename loop pays the
|
||||
O(recipes) index build at most once (on the first recipe-touching rename)
|
||||
instead of rescanning every recipe per renamed file. Also tracks whether
|
||||
any recipe was re-pointed so the session finalizes recipe maintenance only
|
||||
when needed.
|
||||
"""
|
||||
|
||||
def __init__(self, recipe_scanner: Any) -> None:
|
||||
self._recipe_scanner = recipe_scanner
|
||||
self._recipe_hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None
|
||||
self.recipes_touched = False
|
||||
|
||||
@property
|
||||
def recipe_scanner(self) -> Any:
|
||||
return self._recipe_scanner
|
||||
|
||||
async def get_recipe_hash_index(self) -> Optional[Dict[str, List[Dict[str, Any]]]]:
|
||||
"""Return the lora-hash → recipes index, building it on first use."""
|
||||
if self._recipe_scanner is None:
|
||||
return None
|
||||
if self._recipe_hash_index is None:
|
||||
self._recipe_hash_index = (
|
||||
await self._recipe_scanner.build_lora_hash_index()
|
||||
)
|
||||
return self._recipe_hash_index
|
||||
|
||||
|
||||
class ModelLifecycleService:
|
||||
"""Co-ordinate destructive and mutating model operations."""
|
||||
|
||||
@@ -260,7 +299,7 @@ class ModelLifecycleService:
|
||||
|
||||
_require_path_in_library_roots(file_path, self._scanner, label="File path")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
metadata["exclude"] = True
|
||||
|
||||
@@ -315,7 +354,7 @@ class ModelLifecycleService:
|
||||
if not os.path.exists(file_path):
|
||||
raise ValueError("Model file does not exist")
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata_payload = await self._metadata_loader(metadata_path)
|
||||
metadata_payload["exclude"] = False
|
||||
|
||||
@@ -358,10 +397,45 @@ class ModelLifecycleService:
|
||||
|
||||
return await self._scanner.bulk_delete_models(file_paths)
|
||||
|
||||
@asynccontextmanager
|
||||
async def bulk_rename_session(self) -> AsyncIterator[BulkRenameContext]:
|
||||
"""Context for bulk rename loops (filename-template "Apply to Library").
|
||||
|
||||
While active, the per-file ``update_single_model_cache`` resort/persist
|
||||
chain and the per-file recipe folder-metadata refresh/resort are
|
||||
deferred; both run exactly once when the outermost session exits — see
|
||||
``ModelScanner.defer_cache_persist`` and
|
||||
``RecipeScanner.finalize_bulk_filename_updates``. The finalize steps run
|
||||
even on cancellation or mid-loop errors, because files are already
|
||||
renamed on disk and the caches must not be left diverging.
|
||||
|
||||
Yields a :class:`BulkRenameContext` to pass as ``bulk_context`` into
|
||||
each ``rename_model`` call of the loop.
|
||||
"""
|
||||
recipe_scanner = await self._recipe_scanner_factory()
|
||||
context = BulkRenameContext(recipe_scanner)
|
||||
async with self._scanner.defer_cache_persist():
|
||||
try:
|
||||
yield context
|
||||
finally:
|
||||
if recipe_scanner is not None and context.recipes_touched:
|
||||
try:
|
||||
await recipe_scanner.finalize_bulk_filename_updates()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error finalizing bulk recipe updates: %s", exc
|
||||
)
|
||||
|
||||
async def rename_model(
|
||||
self, *, file_path: str, new_file_name: str
|
||||
self, *, file_path: str, new_file_name: str, bulk_context: Optional[BulkRenameContext] = None
|
||||
) -> Dict[str, object]:
|
||||
"""Rename a model and its companion artefacts."""
|
||||
"""Rename a model and its companion artefacts.
|
||||
|
||||
When ``bulk_context`` is given (bulk rename loop), the recipe
|
||||
re-pointing uses the session's prebuilt hash index and defers recipe
|
||||
maintenance to the session finalize; the scanner cache persist is
|
||||
likewise deferred by the surrounding ``bulk_rename_session``.
|
||||
"""
|
||||
|
||||
if not file_path or not new_file_name:
|
||||
raise ValueError("File path and new file name are required")
|
||||
@@ -384,21 +458,26 @@ class ModelLifecycleService:
|
||||
if os.path.exists(new_file_path):
|
||||
raise ValueError("A file with this name already exists")
|
||||
|
||||
patterns = [
|
||||
f"{old_file_name}{old_extension}",
|
||||
f"{old_file_name}.metadata.json",
|
||||
f"{old_file_name}.metadata.json.bak",
|
||||
metadata_filename = os.path.basename(get_metadata_path(file_path))
|
||||
# Sidecars/previews live in the sidecar dir (the model's own dir in
|
||||
# alongside mode, the centralized mirror otherwise); the model file
|
||||
# itself always stays in target_dir.
|
||||
sidecar_dir = get_sidecar_dir(file_path)
|
||||
patterns: List[tuple[str, str]] = [
|
||||
(target_dir, f"{old_file_name}{old_extension}"),
|
||||
(sidecar_dir, metadata_filename),
|
||||
(sidecar_dir, f"{metadata_filename}.bak"),
|
||||
]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{old_file_name}{ext}")
|
||||
patterns.append((sidecar_dir, f"{old_file_name}{ext}"))
|
||||
|
||||
existing_files: List[tuple[str, str]] = []
|
||||
for pattern in patterns:
|
||||
path = os.path.join(target_dir, pattern)
|
||||
for pattern_dir, pattern in patterns:
|
||||
path = os.path.join(pattern_dir, pattern)
|
||||
if os.path.exists(path):
|
||||
existing_files.append((path, pattern))
|
||||
|
||||
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata: Optional[Dict[str, object]] = None
|
||||
hash_value: Optional[str] = None
|
||||
|
||||
@@ -407,20 +486,11 @@ class ModelLifecycleService:
|
||||
raw_hash = metadata.get("sha256") if isinstance(metadata, dict) else None
|
||||
hash_value = raw_hash if isinstance(raw_hash, str) else None
|
||||
|
||||
renamed_files: List[str] = []
|
||||
new_metadata_path: Optional[str] = None
|
||||
new_preview: Optional[str] = None
|
||||
|
||||
for old_path, pattern in existing_files:
|
||||
ext = self._get_multipart_ext(pattern)
|
||||
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
os.rename(old_path, new_path)
|
||||
renamed_files.append(new_path)
|
||||
|
||||
if ext == ".metadata.json":
|
||||
new_metadata_path = new_path
|
||||
renamed_files, new_metadata_path = await asyncio.to_thread(
|
||||
self._rename_companion_files, existing_files, new_file_name
|
||||
)
|
||||
|
||||
if metadata and new_metadata_path:
|
||||
metadata["file_name"] = new_file_name
|
||||
@@ -432,9 +502,9 @@ class ModelLifecycleService:
|
||||
if metadata.get("preview_url"):
|
||||
old_preview = str(metadata["preview_url"])
|
||||
ext = self._get_multipart_ext(old_preview)
|
||||
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
new_preview = os.path.join(
|
||||
get_preview_dir(new_file_path), f"{new_file_name}{ext}"
|
||||
).replace(os.sep, "/")
|
||||
metadata["preview_url"] = new_preview
|
||||
|
||||
await self._metadata_manager.save_metadata(new_file_path, metadata)
|
||||
@@ -445,12 +515,26 @@ class ModelLifecycleService:
|
||||
)
|
||||
|
||||
if hash_value and getattr(self._scanner, "model_type", "") == "lora":
|
||||
recipe_scanner = await self._recipe_scanner_factory()
|
||||
if bulk_context is not None:
|
||||
recipe_scanner = bulk_context.recipe_scanner
|
||||
hash_index = await bulk_context.get_recipe_hash_index()
|
||||
defer_maintenance = True
|
||||
else:
|
||||
recipe_scanner = await self._recipe_scanner_factory()
|
||||
hash_index = None
|
||||
defer_maintenance = False
|
||||
if recipe_scanner:
|
||||
try:
|
||||
await recipe_scanner.update_lora_filename_by_hash(
|
||||
hash_value, new_file_name
|
||||
file_count, cache_count = (
|
||||
await recipe_scanner.update_lora_filename_by_hash(
|
||||
hash_value,
|
||||
new_file_name,
|
||||
hash_index=hash_index,
|
||||
defer_maintenance=defer_maintenance,
|
||||
)
|
||||
)
|
||||
if bulk_context is not None and (file_count or cache_count):
|
||||
bulk_context.recipes_touched = True
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error updating recipe references for %s: %s",
|
||||
@@ -466,6 +550,34 @@ class ModelLifecycleService:
|
||||
"reload_required": False,
|
||||
}
|
||||
|
||||
def _rename_companion_files(
|
||||
self,
|
||||
existing_files: List[tuple[str, str]],
|
||||
new_file_name: str,
|
||||
) -> tuple[List[str], Optional[str]]:
|
||||
"""Rename all companion files, off the event loop thread.
|
||||
|
||||
Runs the blocking ``os.rename`` sequence for one model in a worker
|
||||
thread so a single file's HDD I/O does not stall the event loop.
|
||||
Never parallelized across files: one model's renames stay sequential
|
||||
and the helper holds no locks.
|
||||
"""
|
||||
renamed_files: List[str] = []
|
||||
new_metadata_path: Optional[str] = None
|
||||
|
||||
for old_path, pattern in existing_files:
|
||||
ext = self._get_multipart_ext(pattern)
|
||||
new_path = os.path.join(
|
||||
os.path.dirname(old_path), f"{new_file_name}{ext}"
|
||||
).replace(os.sep, "/")
|
||||
os.rename(old_path, new_path)
|
||||
renamed_files.append(new_path)
|
||||
|
||||
if ext == ".metadata.json":
|
||||
new_metadata_path = new_path
|
||||
|
||||
return renamed_files, new_metadata_path
|
||||
|
||||
@staticmethod
|
||||
def _get_multipart_ext(filename: str) -> str:
|
||||
"""Return the extension for files with compound suffixes."""
|
||||
|
||||
@@ -112,7 +112,10 @@ class _RateLimitRetryHelper:
|
||||
|
||||
# Labels of providers that are free to consult even while a network provider
|
||||
# is rate-limited (local lookups, no vendor cost).
|
||||
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite"})
|
||||
# "openmodeldb_api" qualifies because its lookups hit a local index built from
|
||||
# a cached bulk dump; the underlying site is a static host (GitHub Pages), so
|
||||
# even a cold cache refresh is a single cheap GET against a different vendor.
|
||||
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite", "openmodeldb_api"})
|
||||
|
||||
|
||||
class ModelMetadataProvider(ABC):
|
||||
@@ -180,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"""
|
||||
|
||||
@@ -219,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"""
|
||||
|
||||
@@ -480,6 +499,36 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
class OpenModelDBModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Provider that serves upscaler metadata from the OpenModelDB catalogue.
|
||||
|
||||
Only hash lookups are supported: OpenModelDB has no per-model or version
|
||||
API, so the remaining provider surface intentionally returns None and lets
|
||||
the fallback chain continue to the next provider.
|
||||
"""
|
||||
|
||||
def __init__(self, openmodeldb_client):
|
||||
self.client = openmodeldb_client
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
return await self.client.get_model_by_hash(model_hash)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Not supported: OpenModelDB models have no version history API."""
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Not supported: OpenModelDB models have no version history API."""
|
||||
return None
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Not supported: OpenModelDB models have no version history API."""
|
||||
return None, "Model not found"
|
||||
|
||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Not supported by the OpenModelDB provider."""
|
||||
return None
|
||||
|
||||
class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
"""Try providers in order, return first successful result.
|
||||
|
||||
@@ -750,6 +799,60 @@ 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*.
|
||||
|
||||
Used by the metadata sync service to skip providers that cannot apply
|
||||
to a given model (e.g. OpenModelDB only indexes upscalers), so their
|
||||
cold-start cost (a bulk dump download) is never paid pointlessly.
|
||||
"""
|
||||
kept = [
|
||||
(label, provider)
|
||||
for provider, label in self._iter_providers()
|
||||
if label not in labels
|
||||
]
|
||||
if len(kept) == len(self.providers):
|
||||
return self
|
||||
if not kept:
|
||||
# Never produce an empty chain; the caller still needs a provider
|
||||
# that can at least report "Model not found".
|
||||
return self
|
||||
return FallbackMetadataProvider(
|
||||
kept,
|
||||
rate_limit_retry_limit=self._rate_limit_retry_limit,
|
||||
rate_limit_base_delay=self._rate_limit_base_delay,
|
||||
rate_limit_max_delay=self._rate_limit_max_delay,
|
||||
rate_limit_jitter_ratio=self._rate_limit_jitter_ratio,
|
||||
)
|
||||
|
||||
async def _call_with_rate_limit(self, label: str, func, *args, **kwargs):
|
||||
return await self._rate_limit_helper.run(label, func, *args, **kwargs)
|
||||
|
||||
@@ -848,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"""
|
||||
|
||||
|
||||
@@ -432,7 +432,6 @@ class SearchStrategy:
|
||||
"tags": False,
|
||||
"recursive": True,
|
||||
"creator": False,
|
||||
"hash": False,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -495,13 +494,14 @@ class SearchStrategy:
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
# Hash search is always exact (never fuzzy): match the full
|
||||
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
|
||||
if options.get("hash", False):
|
||||
hash_query = search_lower.strip()
|
||||
if hash_query and self._matches_hash(item, hash_query):
|
||||
results.append(item)
|
||||
continue
|
||||
# Hash/id search is always exact (never fuzzy) and always on: it
|
||||
# matches the full sha256, its autov2 prefix (first 10 chars),
|
||||
# the autov3 hash, or the Civitai model/version ids. Exact-match
|
||||
# semantics mean it adds no noise to ordinary keyword searches.
|
||||
hash_query = search_lower.strip()
|
||||
if hash_query and self._matches_hash(item, hash_query):
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
@@ -515,6 +515,18 @@ class SearchStrategy:
|
||||
autov3 = item.get("autov3")
|
||||
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
|
||||
return True
|
||||
civitai = item.get("civitai")
|
||||
if isinstance(civitai, dict):
|
||||
# A model card corresponds to one Civitai version: `modelId` is
|
||||
# the model id (may match several cards when the library holds
|
||||
# multiple versions), `id` is the version id (unique per card).
|
||||
for key in ("modelId", "model_id", "id"):
|
||||
value = civitai.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value).strip()
|
||||
if value_str and value_str != "0" and hash_query == value_str:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _matches(
|
||||
|
||||
+1610
-203
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""External model-source providers (Hugging Face, ModelScope, TensorArt).
|
||||
"""External model-source providers (Hugging Face, ModelScope, TensorArt, OpenModelDB).
|
||||
|
||||
This package is the single abstraction over "a site that hosts models and
|
||||
a model card". See :mod:`py.services.model_sources.base` for the provider
|
||||
@@ -30,6 +30,7 @@ from .hydration import (
|
||||
resolve_site_base_model,
|
||||
)
|
||||
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||
from .openmodeldb import OpenModelDBSource
|
||||
from .registry import (
|
||||
LEGACY_HF_URL_FIELD,
|
||||
SOURCE_PLATFORM_FIELD,
|
||||
@@ -59,6 +60,7 @@ __all__ = [
|
||||
"HuggingFaceSource",
|
||||
"ModelScopeIntlSource",
|
||||
"ModelScopeSource",
|
||||
"OpenModelDBSource",
|
||||
"SOURCE_PLATFORM_FIELD",
|
||||
"SOURCE_URL_FIELD",
|
||||
"SourceRef",
|
||||
|
||||
@@ -47,6 +47,7 @@ GROUP_PREFIXES: dict[str, str] = {
|
||||
"modelscope": "ms",
|
||||
"modelscope-ai": "msai",
|
||||
"tensorart": "ta",
|
||||
"openmodeldb": "omdb",
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +289,11 @@ class ModelSource:
|
||||
#: Sub-directory the "use default paths" template places downloads in.
|
||||
default_subdir: str = ""
|
||||
|
||||
#: Source id used to build the example URL shown in UI copy and error
|
||||
#: messages. ``owner/name`` suits repository sites; sites with a
|
||||
#: different identity shape override it with a real example.
|
||||
example_source_id: str = "user/repo"
|
||||
|
||||
#: Lenient pattern used to recognise URLs already stored in metadata.
|
||||
#: Captures the site-specific source id in group ``id``.
|
||||
url_pattern: re.Pattern[str] | None = None
|
||||
@@ -340,6 +346,26 @@ class ModelSource:
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def is_valid_source_id(self, source_id: str) -> bool:
|
||||
"""Return ``True`` when *source_id* is a safe id on this site.
|
||||
|
||||
Defaults to the ``owner/name`` repository rule; sites whose ids are
|
||||
not repositories (OpenModelDB's flat model ids) override it.
|
||||
"""
|
||||
|
||||
return is_valid_source_id(source_id)
|
||||
|
||||
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
|
||||
"""Path segments appended to the model root by "use default paths".
|
||||
|
||||
Defaults to ``<default_subdir>/<owner>/<repo>`` so downloads from
|
||||
repository sites stay namespaced by author. Sites without an
|
||||
owner/repo split override it.
|
||||
"""
|
||||
|
||||
owner, repo_name = source_id.split("/", 1)
|
||||
return (self.default_subdir, owner, repo_name)
|
||||
|
||||
def asset_base_url(self, source_id: str, revision: str = "") -> str:
|
||||
"""Base URL used to resolve repository-relative asset paths."""
|
||||
|
||||
@@ -432,6 +458,18 @@ class ModelSource:
|
||||
f"{self.label or self.platform} does not support downloads", status=400
|
||||
)
|
||||
|
||||
async def resolve_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
"""Resolve the download URL for one file, allowing async lookups.
|
||||
|
||||
Defaults to the synchronous :meth:`file_download_url`; sites whose
|
||||
download URL is not derivable from the id alone (OpenModelDB stores
|
||||
the URL inside its catalogue entry) override this to look it up.
|
||||
"""
|
||||
|
||||
return self.file_download_url(source_id, filename, revision)
|
||||
|
||||
def resolve_revision(self, revision: str = "") -> str:
|
||||
"""Return *revision*, falling back to this site's default branch."""
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""OpenModelDB model source (upscaler catalogue).
|
||||
|
||||
OpenModelDB (https://openmodeldb.info) is a static catalogue of upscaler
|
||||
models. Unlike the repository-based sources (Hugging Face, ModelScope) a
|
||||
model id here is a flat token (``4x-UltraSharp``) that *is* the published
|
||||
model identity: there is no owner/repo split, no revision, and no README.
|
||||
Everything the source needs — the resource download URLs, sizes, sha256
|
||||
hashes, tags and example images — comes from the site's bulk JSON dumps via
|
||||
:class:`~py.services.openmodeldb_client.OpenModelDBClient`, which caches the
|
||||
catalogue on disk, so every method below is a local lookup once warmed.
|
||||
|
||||
Only PyTorch resources (``.pth`` / ``.safetensors``) are listed for download:
|
||||
``.onnx`` is not a loadable weight format for the supported model types (see
|
||||
:data:`py.utils.constants.MODEL_FILE_EXTENSIONS`). Resources can carry
|
||||
mirror URLs; only the primary URL is ever used (see
|
||||
:meth:`OpenModelDBClient.primary_url`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .base import (
|
||||
ModelCardContext,
|
||||
ModelSource,
|
||||
ModelSourceCache,
|
||||
ModelSourceError,
|
||||
filter_weight_files,
|
||||
)
|
||||
from ..openmodeldb_client import OPENMODELDB_SITE_BASE, OpenModelDBClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Model ids are flat tokens (``4x-UltraSharp``), usable as a path segment.
|
||||
_SOURCE_ID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
|
||||
|
||||
_URL_PATTERN = re.compile(
|
||||
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)"
|
||||
)
|
||||
_STRICT_URL_PATTERN = re.compile(
|
||||
r"https?://(?:www\.)?openmodeldb\.info/models/(?P<id>[A-Za-z0-9_][A-Za-z0-9_.\-]*)/?$"
|
||||
)
|
||||
|
||||
#: Resource platforms whose files ComfyUI can load.
|
||||
_DOWNLOADABLE_PLATFORMS = frozenset({"pytorch"})
|
||||
|
||||
|
||||
class OpenModelDBSource(ModelSource):
|
||||
"""OpenModelDB (``openmodeldb.info``)."""
|
||||
|
||||
platform = "openmodeldb"
|
||||
label = "OpenModelDB"
|
||||
supports_enrichment = True
|
||||
supports_download = True
|
||||
default_revision = ""
|
||||
default_subdir = "openmodeldb"
|
||||
example_source_id = "4x-UltraSharp"
|
||||
url_pattern = _URL_PATTERN
|
||||
strict_url_pattern = _STRICT_URL_PATTERN
|
||||
|
||||
def canonical_url(self, source_id: str) -> str:
|
||||
return f"{OPENMODELDB_SITE_BASE}/models/{source_id}"
|
||||
|
||||
def is_valid_source_id(self, source_id: str) -> bool:
|
||||
"""OpenModelDB ids are flat tokens, not ``owner/name`` repositories."""
|
||||
|
||||
return bool(isinstance(source_id, str) and _SOURCE_ID.match(source_id))
|
||||
|
||||
def default_subdir_parts(self, source_id: str) -> tuple[str, ...]:
|
||||
"""Flat catalogue: there is no owner/repo split to mirror on disk."""
|
||||
|
||||
return (self.default_subdir,)
|
||||
|
||||
async def fetch_model_card_context(
|
||||
self,
|
||||
source_id: str,
|
||||
filename: str = "",
|
||||
*,
|
||||
sha256: str = "",
|
||||
cache: Optional["ModelSourceCache"] = None,
|
||||
) -> ModelCardContext:
|
||||
"""Build the card extras from the cached catalogue entry.
|
||||
|
||||
OpenModelDB has no README; the catalogue entry itself carries the
|
||||
description, license, tags and example images, so the context is the
|
||||
whole card. The catalogue is bulk-loaded and disk-cached, so no
|
||||
per-run memo is needed.
|
||||
"""
|
||||
|
||||
try:
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
found = await client.get_model_entry(source_id)
|
||||
except Exception as exc: # never break enrichment on a lookup fault
|
||||
logger.debug("OpenModelDB context lookup failed for %s: %s", source_id, exc)
|
||||
return ModelCardContext()
|
||||
if found is None:
|
||||
return ModelCardContext()
|
||||
entry = found[1]
|
||||
|
||||
scale = entry.get("scale")
|
||||
arch_name = client._resolve_architecture_name(entry)
|
||||
# e.g. "ESRGAN 4x" — closest thing upscalers have to a base model,
|
||||
# recorded as a hint rather than a canonical base-model name.
|
||||
base_hint = (
|
||||
f"{arch_name} {scale}x".strip()
|
||||
if arch_name and isinstance(scale, (int, float))
|
||||
else arch_name
|
||||
)
|
||||
description = entry.get("description")
|
||||
|
||||
return ModelCardContext(
|
||||
description=description if isinstance(description, str) else "",
|
||||
model_name=entry.get("name") or source_id,
|
||||
license=entry.get("license") or "",
|
||||
model_type="Upscaler",
|
||||
base_model_aliases=[base_hint] if base_hint else [],
|
||||
official_tags=client._resolve_tags(entry),
|
||||
example_images=client.example_image_urls(entry),
|
||||
source_model_id=source_id,
|
||||
)
|
||||
|
||||
async def list_files(
|
||||
self, source_id: str, revision: str = ""
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List the entry's directly downloadable PyTorch resources.
|
||||
|
||||
Resources whose only mirrors are HTML-gateway hosts (mediafire,
|
||||
mega.nz, drive.google.com) are skipped: they serve a web page, not
|
||||
the file bytes. When every resource is mirror-only this raises a
|
||||
manual-download hint instead of returning an empty list, which the
|
||||
download dialog would otherwise misreport as "no model files".
|
||||
"""
|
||||
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
entry = await self._require_entry(client, source_id)
|
||||
|
||||
saw_mirror_only = False
|
||||
entries = []
|
||||
for resource in entry.get("resources") or []:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
if str(resource.get("platform") or "").lower() not in _DOWNLOADABLE_PLATFORMS:
|
||||
continue
|
||||
url = client.direct_url(resource)
|
||||
if not url:
|
||||
saw_mirror_only = True
|
||||
continue
|
||||
size = resource.get("size")
|
||||
entries.append(
|
||||
(
|
||||
client.resource_filename(source_id, resource),
|
||||
size if isinstance(size, (int, float)) else 0,
|
||||
)
|
||||
)
|
||||
|
||||
if not entries and saw_mirror_only:
|
||||
raise ModelSourceError(
|
||||
f"None of this model's mirrors support direct download; "
|
||||
f"download it manually from {self.canonical_url(source_id)}",
|
||||
status=400,
|
||||
)
|
||||
|
||||
return filter_weight_files(entries)
|
||||
|
||||
async def resolve_download_url(
|
||||
self, source_id: str, filename: str, revision: str = ""
|
||||
) -> str:
|
||||
"""Resolve the direct download URL of one resource by filename."""
|
||||
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
entry = await self._require_entry(client, source_id)
|
||||
|
||||
resource = client.find_resource_by_filename(
|
||||
source_id, entry, os.path.basename(filename)
|
||||
)
|
||||
if resource is None:
|
||||
raise ModelSourceError(
|
||||
f"'{filename}' is not a downloadable resource of '{source_id}'",
|
||||
status=404,
|
||||
)
|
||||
url = client.direct_url(resource)
|
||||
if not url:
|
||||
host = urlparse(client.primary_url(resource)).netloc or "this mirror"
|
||||
raise ModelSourceError(
|
||||
f"This mirror ({host}) requires manual download from "
|
||||
f"{self.canonical_url(source_id)}",
|
||||
status=400,
|
||||
)
|
||||
return url
|
||||
|
||||
async def _require_entry(
|
||||
self, client: OpenModelDBClient, source_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Return the catalogue entry, raising a mapped error otherwise."""
|
||||
|
||||
if not await client.catalogue_ready():
|
||||
raise ModelSourceError("OpenModelDB catalogue unavailable", status=502)
|
||||
found = await client.get_model_entry(source_id)
|
||||
if found is None:
|
||||
raise ModelSourceError(
|
||||
f"Model '{source_id}' not found on OpenModelDB", status=404
|
||||
)
|
||||
return found[1]
|
||||
|
||||
|
||||
__all__ = ["OpenModelDBSource"]
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Dict, Mapping, Optional
|
||||
from .base import GROUP_PREFIXES, ModelSource, SourceRef, clean_source_url
|
||||
from .huggingface import HuggingFaceSource
|
||||
from .modelscope import ModelScopeIntlSource, ModelScopeSource
|
||||
from .openmodeldb import OpenModelDBSource
|
||||
from .tensorart import TensorArtSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,6 +27,7 @@ _SOURCES: tuple[ModelSource, ...] = (
|
||||
ModelScopeSource(),
|
||||
ModelScopeIntlSource(),
|
||||
TensorArtSource(),
|
||||
OpenModelDBSource(),
|
||||
)
|
||||
|
||||
_BY_PLATFORM: Dict[str, ModelSource] = {s.platform: s for s in _SOURCES}
|
||||
|
||||
@@ -42,6 +42,7 @@ class TensorArtSource(ModelSource):
|
||||
label = "TensorArt"
|
||||
supports_enrichment = False
|
||||
supports_download = False
|
||||
example_source_id = "827823520299086029"
|
||||
url_pattern = _URL_PATTERN
|
||||
strict_url_pattern = _STRICT_URL_PATTERN
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
"""Client for the OpenModelDB bulk JSON API.
|
||||
|
||||
OpenModelDB (https://openmodeldb.info) is a static catalogue of upscaler
|
||||
models. It exposes no per-model or by-hash endpoint — only bulk JSON dumps
|
||||
(``/api/v1/models.json`` and friends), so this client downloads the dumps
|
||||
once, caches them on disk with a TTL, honors ETag/Last-Modified on refresh,
|
||||
and builds an in-memory SHA256 -> model index for read-only metadata lookups.
|
||||
|
||||
Every catalogue resource carries a ``sha256`` and a byte ``size``, which is
|
||||
what makes hash-based matching against local files possible. Lookups degrade
|
||||
gracefully: when the catalogue cannot be fetched (offline, upstream failure)
|
||||
the stale disk cache is used, and if there is no cache at all the lookup
|
||||
reports "not found" so the metadata fallback chain simply moves on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
from ..utils.constants import MODEL_FILE_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENMODELDB_API_BASE = "https://openmodeldb.info/api/v1"
|
||||
OPENMODELDB_SITE_BASE = "https://openmodeldb.info"
|
||||
|
||||
#: Value emitted as ``source`` in the synthesized version dict; the metadata
|
||||
#: sync service persists it as the model's ``metadata_source``.
|
||||
METADATA_SOURCE_VALUE = "openmodeldb"
|
||||
|
||||
#: Hosts whose URLs serve an HTML interstitial page instead of the raw file
|
||||
#: bytes. Downloading from them would silently save a web page as ``.pth``
|
||||
#: (the download flow does not verify the sha256 afterwards), so the model
|
||||
#: source layer rejects them with a manual-download hint. Kept deliberately
|
||||
#: small and explicit.
|
||||
HTML_GATEWAY_HOSTS = frozenset({"mediafire.com", "mega.nz", "drive.google.com"})
|
||||
|
||||
|
||||
def is_html_gateway_url(url: str) -> bool:
|
||||
"""Return ``True`` when *url* points at a known HTML-gateway host."""
|
||||
|
||||
if not isinstance(url, str) or not url:
|
||||
return False
|
||||
try:
|
||||
host = urlparse(url).netloc.lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return any(host == g or host.endswith(f".{g}") for g in HTML_GATEWAY_HOSTS)
|
||||
|
||||
|
||||
def _is_ephemeral_viewer_url(url: str) -> bool:
|
||||
"""Return ``True`` for imgdiff.net session URLs.
|
||||
|
||||
Paired comparisons are hosted as ephemeral imgdiff viewer sessions
|
||||
(``/api/image.php?id=...``) that expire shortly after the site build;
|
||||
they 404 when used as an ``<img>`` source and must never be emitted as a
|
||||
displayable image URL.
|
||||
"""
|
||||
|
||||
return isinstance(url, str) and "imgdiff.net/api/" in url
|
||||
|
||||
#: Bulk dumps consumed by the client. Only ``models`` is strictly required;
|
||||
#: the rest resolve ids to human-readable names and degrade to raw ids.
|
||||
_DUMP_NAMES = ("models", "users", "tags", "architectures")
|
||||
|
||||
#: How long a fetched catalogue is considered fresh before a revalidation
|
||||
#: request is made. The site only changes when it is rebuilt (hours to days),
|
||||
#: so a daily TTL avoids re-downloading the ~1.4MB models dump on every
|
||||
#: lookup while still picking up new models reasonably fast.
|
||||
CACHE_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
_META_FILENAME = "_meta.json"
|
||||
|
||||
|
||||
class OpenModelDBClient:
|
||||
"""Hash-lookup client over a locally cached OpenModelDB catalogue dump."""
|
||||
|
||||
_instance: Optional["OpenModelDBClient"] = None
|
||||
_instance_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "OpenModelDBClient":
|
||||
"""Get the singleton instance of OpenModelDBClient."""
|
||||
async with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
|
||||
# Register this client as a metadata provider (mirrors the
|
||||
# CivitAI/CivArchive client bootstrap).
|
||||
from .model_metadata_provider import (
|
||||
ModelMetadataProviderManager,
|
||||
OpenModelDBModelMetadataProvider,
|
||||
)
|
||||
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
provider_manager.register_provider(
|
||||
"openmodeldb",
|
||||
OpenModelDBModelMetadataProvider(cls._instance),
|
||||
False,
|
||||
)
|
||||
|
||||
return cls._instance
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_dir: Optional[str] = None,
|
||||
ttl_seconds: float = CACHE_TTL_SECONDS,
|
||||
) -> None:
|
||||
# Guard re-initialization for the singleton pattern.
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self._cache_dir_override = cache_dir
|
||||
self._ttl_seconds = ttl_seconds
|
||||
|
||||
self._models: Dict[str, Dict[str, Any]] = {}
|
||||
self._users: Dict[str, Dict[str, Any]] = {}
|
||||
self._tags: Dict[str, Dict[str, Any]] = {}
|
||||
self._architectures: Dict[str, Dict[str, Any]] = {}
|
||||
# sha256 (lowercase) -> (model_id, model entry, matching resource)
|
||||
self._index: Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]] = {}
|
||||
self._loaded_at: float = 0.0
|
||||
self._load_lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_model_by_hash(
|
||||
self, model_hash: str
|
||||
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""Find an upscaler model by SHA256 hash.
|
||||
|
||||
Returns a CivitAI-shaped version dict (same contract as the other
|
||||
metadata providers) or ``(None, reason)``.
|
||||
"""
|
||||
if not model_hash or not isinstance(model_hash, str):
|
||||
return None, "Model not found"
|
||||
|
||||
try:
|
||||
loaded = await self._ensure_loaded()
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("OpenModelDB lookup failed for %s: %s", model_hash[:10], exc)
|
||||
return None, str(exc)
|
||||
|
||||
if not loaded:
|
||||
return None, "OpenModelDB catalogue unavailable"
|
||||
|
||||
hit = self._index.get(model_hash.lower())
|
||||
if hit is None:
|
||||
return None, "Model not found"
|
||||
|
||||
model_id, model_entry, resource = hit
|
||||
return self._to_civitai_version(model_id, model_entry, resource), None
|
||||
|
||||
async def catalogue_ready(self) -> bool:
|
||||
"""Return ``True`` when the catalogue is loaded (or loadable)."""
|
||||
|
||||
try:
|
||||
return await self._ensure_loaded()
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("OpenModelDB catalogue load failed: %s", exc)
|
||||
return False
|
||||
|
||||
async def get_model_entry(
|
||||
self, model_id: str
|
||||
) -> Optional[Tuple[str, Dict[str, Any]]]:
|
||||
"""Return ``(model_id, catalogue entry)`` for *model_id*, or ``None``.
|
||||
|
||||
Loads the catalogue on first use; an unavailable catalogue and an
|
||||
unknown id both yield ``None`` (callers that need to distinguish the
|
||||
two can check :meth:`catalogue_ready` first).
|
||||
"""
|
||||
if not model_id or not isinstance(model_id, str):
|
||||
return None
|
||||
if not await self.catalogue_ready():
|
||||
return None
|
||||
entry = self._models.get(model_id)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
return model_id, entry
|
||||
|
||||
def find_resource_by_filename(
|
||||
self, model_id: str, entry: Dict[str, Any], filename: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Match a resource by its derived filename (see :meth:`resource_filename`)."""
|
||||
target = (filename or "").strip().lower()
|
||||
if not target:
|
||||
return None
|
||||
for resource in entry.get("resources") or []:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
if self.resource_filename(model_id, resource).lower() == target:
|
||||
return resource
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resource_filename(model_id: str, resource: Dict[str, Any]) -> str:
|
||||
"""Derive the local filename for a catalogue resource.
|
||||
|
||||
The download URL's basename is not authoritative — mirrors like
|
||||
mediafire put the real filename mid-path
|
||||
(``/file/<key>/90s_Sonic_2x.pth/file``) and folder links (mega.nz)
|
||||
have no filename at all. Strategy: the first URL path segment whose
|
||||
extension is a known model format, else ``{model_id}.{type}`` (the
|
||||
catalogue's ``type`` field is authoritative).
|
||||
"""
|
||||
urls = resource.get("urls")
|
||||
for url in urls if isinstance(urls, list) else []:
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
path = url.split("?", 1)[0].split("#", 1)[0]
|
||||
for segment in path.split("/"):
|
||||
if os.path.splitext(segment)[1].lower() in MODEL_FILE_EXTENSIONS:
|
||||
return segment
|
||||
resource_type = str(resource.get("type") or "").lower()
|
||||
extension = (
|
||||
resource_type if resource_type in {"pth", "safetensors", "onnx"} else "bin"
|
||||
)
|
||||
return f"{model_id}.{extension}"
|
||||
|
||||
@staticmethod
|
||||
def primary_url(resource: Dict[str, Any]) -> str:
|
||||
"""Return the resource's primary download URL.
|
||||
|
||||
Only the first URL is used: additional entries are mirrors that may
|
||||
need site-specific handling (e.g. mega.nz) and are never tried
|
||||
automatically.
|
||||
"""
|
||||
urls = resource.get("urls")
|
||||
if isinstance(urls, list):
|
||||
for url in urls:
|
||||
if isinstance(url, str) and url.startswith("http"):
|
||||
return url
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def direct_url(resource: Dict[str, Any]) -> str:
|
||||
"""Return the first URL that serves raw bytes, or ``""``.
|
||||
|
||||
HTML-gateway hosts (mediafire, mega.nz, drive.google.com — see
|
||||
:data:`HTML_GATEWAY_HOSTS`) serve an interstitial page instead of the
|
||||
file, so they are skipped here and reported to the user instead.
|
||||
"""
|
||||
urls = resource.get("urls")
|
||||
if isinstance(urls, list):
|
||||
for url in urls:
|
||||
if (
|
||||
isinstance(url, str)
|
||||
and url.startswith("http")
|
||||
and not is_html_gateway_url(url)
|
||||
):
|
||||
return url
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _absolutize(url: str) -> str:
|
||||
"""Turn a site-relative path (``/thumbs/...``) into an absolute URL."""
|
||||
|
||||
if isinstance(url, str) and url.startswith("/"):
|
||||
return f"{OPENMODELDB_SITE_BASE}{url}"
|
||||
return url
|
||||
|
||||
def _paired_display_url(self, image: Dict[str, Any]) -> str:
|
||||
"""Return the displayable URL for a paired comparison image.
|
||||
|
||||
Prefers the site-hosted thumbnail: the ``LR``/``SR`` originals are
|
||||
frequently ephemeral imgdiff session URLs that 404 outside the
|
||||
viewer. Falls back to the SR (then LR) original only when it is not
|
||||
one of those session URLs.
|
||||
"""
|
||||
thumbnail = image.get("thumbnail")
|
||||
if isinstance(thumbnail, str) and thumbnail:
|
||||
return self._absolutize(thumbnail)
|
||||
for key in ("SR", "LR"):
|
||||
original = image.get(key)
|
||||
if (
|
||||
isinstance(original, str)
|
||||
and original
|
||||
and not _is_ephemeral_viewer_url(original)
|
||||
):
|
||||
return original
|
||||
return ""
|
||||
|
||||
def _model_preview_url(self, entry: Dict[str, Any]) -> str:
|
||||
"""Return the model-level thumbnail URL, mirroring the site's own
|
||||
``getPreviewImage`` precedence (paired → SR, standalone → url)."""
|
||||
thumbnail = entry.get("thumbnail")
|
||||
if not isinstance(thumbnail, dict):
|
||||
return ""
|
||||
if thumbnail.get("type") == "paired":
|
||||
url = thumbnail.get("SR") or thumbnail.get("LR")
|
||||
else:
|
||||
url = thumbnail.get("url")
|
||||
if isinstance(url, str) and url:
|
||||
return self._absolutize(url)
|
||||
return ""
|
||||
|
||||
def example_image_urls(self, entry: Dict[str, Any]) -> List[str]:
|
||||
"""Return displayable example-image URLs, model thumbnail first.
|
||||
|
||||
Never contains ephemeral imgdiff session URLs; standalone images keep
|
||||
their direct URLs (regular image hosts are hotlinkable).
|
||||
"""
|
||||
urls: List[str] = []
|
||||
lead = self._model_preview_url(entry)
|
||||
if lead:
|
||||
urls.append(lead)
|
||||
for image in entry.get("images") or []:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
if image.get("type") == "paired":
|
||||
url = self._paired_display_url(image)
|
||||
else:
|
||||
url = image.get("url")
|
||||
if isinstance(url, str) and url and url not in urls:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Catalogue loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _ensure_loaded(self) -> bool:
|
||||
"""Ensure the in-memory index is built, refreshing stale caches."""
|
||||
async with self._load_lock:
|
||||
if self._index and (time.monotonic() - self._loaded_at) < self._ttl_seconds:
|
||||
return True
|
||||
|
||||
meta = self._read_meta()
|
||||
fetched_at = float(meta.get("fetched_at") or 0.0)
|
||||
disk_fresh = (
|
||||
fetched_at > 0
|
||||
and (time.time() - fetched_at) < self._ttl_seconds
|
||||
and all(os.path.exists(self._dump_path(name)) for name in _DUMP_NAMES)
|
||||
)
|
||||
|
||||
if disk_fresh:
|
||||
if self._load_from_disk():
|
||||
return True
|
||||
# Corrupt disk cache: fall through to a network refresh.
|
||||
|
||||
if await self._refresh_from_network(meta):
|
||||
return True
|
||||
|
||||
# Network failed or was blocked: fall back to whatever is on disk,
|
||||
# however stale — old metadata beats none.
|
||||
if fetched_at > 0 and self._load_from_disk():
|
||||
logger.info("Using stale OpenModelDB cache (network refresh failed)")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _load_from_disk(self) -> bool:
|
||||
"""Load all dumps from the disk cache and rebuild the index."""
|
||||
payloads: Dict[str, Dict[str, Any]] = {}
|
||||
for name in _DUMP_NAMES:
|
||||
path = self._dump_path(name)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
if name == "models":
|
||||
return False
|
||||
data = {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read OpenModelDB cache %s: %s", path, exc)
|
||||
if name == "models":
|
||||
return False
|
||||
data = {}
|
||||
payloads[name] = data if isinstance(data, dict) else {}
|
||||
|
||||
if not payloads["models"]:
|
||||
return False
|
||||
|
||||
self._install_payloads(payloads)
|
||||
return True
|
||||
|
||||
async def _refresh_from_network(self, meta: Dict[str, Any]) -> bool:
|
||||
"""Revalidate cached dumps against the site and rebuild the index.
|
||||
|
||||
Honors ETag/Last-Modified via a HEAD probe: an unchanged dump keeps
|
||||
its cached body, so a TTL expiry without upstream changes costs one
|
||||
tiny request per dump instead of a full download.
|
||||
"""
|
||||
payloads: Dict[str, Dict[str, Any]] = {}
|
||||
etags: Dict[str, str] = dict(meta.get("etags") or {})
|
||||
last_modified: Dict[str, str] = dict(meta.get("last_modified") or {})
|
||||
|
||||
for name in _DUMP_NAMES:
|
||||
payload, etag, modified = await self._fetch_dump(
|
||||
name,
|
||||
known_etag=etags.get(name) or "",
|
||||
known_last_modified=last_modified.get(name) or "",
|
||||
)
|
||||
if payload is None:
|
||||
if name == "models":
|
||||
return False
|
||||
payload = {}
|
||||
payloads[name] = payload
|
||||
if etag:
|
||||
etags[name] = etag
|
||||
if modified:
|
||||
last_modified[name] = modified
|
||||
|
||||
self._install_payloads(payloads)
|
||||
self._write_cache(payloads, etags, last_modified)
|
||||
return True
|
||||
|
||||
async def _fetch_dump(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
known_etag: str,
|
||||
known_last_modified: str,
|
||||
) -> Tuple[Optional[Dict[str, Any]], str, str]:
|
||||
"""Fetch one dump, returning ``(body, etag, last_modified)``.
|
||||
|
||||
``body`` is ``None`` when the fetch failed and there is no usable
|
||||
cached copy. When the HEAD probe shows the resource unchanged, the
|
||||
cached body is returned without a full download.
|
||||
"""
|
||||
url = f"{OPENMODELDB_API_BASE}/{name}.json"
|
||||
disk_path = self._dump_path(name)
|
||||
have_cached = os.path.exists(disk_path)
|
||||
|
||||
downloader = await get_downloader()
|
||||
|
||||
head_etag = ""
|
||||
head_modified = ""
|
||||
try:
|
||||
head_ok, head_headers = await downloader.get_response_headers(url)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("OpenModelDB HEAD probe failed for %s: %s", url, exc)
|
||||
head_ok, head_headers = False, {}
|
||||
|
||||
if head_ok and isinstance(head_headers, dict):
|
||||
# aiohttp headers are case-insensitive; plain dicts in tests are not.
|
||||
head_etag = str(head_headers.get("ETag") or head_headers.get("etag") or "")
|
||||
head_modified = str(
|
||||
head_headers.get("Last-Modified") or head_headers.get("last-modified") or ""
|
||||
)
|
||||
|
||||
if (
|
||||
have_cached
|
||||
and known_etag
|
||||
and head_etag
|
||||
and head_etag == known_etag
|
||||
):
|
||||
cached = self._read_dump_file(disk_path)
|
||||
if cached is not None:
|
||||
logger.debug("OpenModelDB %s unchanged (etag match); using cache", name)
|
||||
return cached, known_etag, known_last_modified or head_modified
|
||||
|
||||
success, payload = await downloader.make_request("GET", url, use_auth=False)
|
||||
if isinstance(payload, RateLimitError):
|
||||
raise payload
|
||||
if not success or not isinstance(payload, dict):
|
||||
logger.warning(
|
||||
"OpenModelDB %s fetch failed: %s",
|
||||
name,
|
||||
payload if isinstance(payload, str) else "unexpected payload",
|
||||
)
|
||||
if have_cached:
|
||||
cached = self._read_dump_file(disk_path)
|
||||
if cached is not None:
|
||||
return cached, known_etag, known_last_modified
|
||||
return None, known_etag, known_last_modified
|
||||
|
||||
return payload, head_etag or known_etag, head_modified or known_last_modified
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Index and transformation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _install_payloads(self, payloads: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Install dump payloads and rebuild the sha256 index."""
|
||||
self._models = payloads.get("models") or {}
|
||||
self._users = payloads.get("users") or {}
|
||||
self._tags = payloads.get("tags") or {}
|
||||
self._architectures = payloads.get("architectures") or {}
|
||||
self._index = self._build_index(self._models)
|
||||
self._loaded_at = time.monotonic()
|
||||
logger.debug(
|
||||
"OpenModelDB catalogue loaded: %d models, %d indexed hashes",
|
||||
len(self._models),
|
||||
len(self._index),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_index(
|
||||
models: Dict[str, Dict[str, Any]]
|
||||
) -> Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]]:
|
||||
"""Build the sha256 -> (model_id, model entry, resource) index."""
|
||||
index: Dict[str, Tuple[str, Dict[str, Any], Dict[str, Any]]] = {}
|
||||
for model_id, entry in models.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
resources = entry.get("resources")
|
||||
if not isinstance(resources, list):
|
||||
continue
|
||||
for resource in resources:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
sha256 = resource.get("sha256")
|
||||
if not isinstance(sha256, str) or not sha256:
|
||||
continue
|
||||
# First writer wins: duplicate hashes across catalogue entries
|
||||
# are ambiguous and cannot be disambiguated locally.
|
||||
index.setdefault(sha256.lower(), (model_id, entry, resource))
|
||||
return index
|
||||
|
||||
def _resolve_authors(self, entry: Dict[str, Any]) -> Tuple[str, List[str]]:
|
||||
"""Resolve the author field to a display name plus the raw user ids."""
|
||||
raw = entry.get("author")
|
||||
author_ids = raw if isinstance(raw, list) else [raw]
|
||||
ids = [str(a) for a in author_ids if isinstance(a, str) and a]
|
||||
names: List[str] = []
|
||||
for author_id in ids:
|
||||
user = self._users.get(author_id)
|
||||
name = user.get("name") if isinstance(user, dict) else None
|
||||
names.append(name if isinstance(name, str) and name else author_id)
|
||||
return ", ".join(names), ids
|
||||
|
||||
def _resolve_tags(self, entry: Dict[str, Any]) -> List[str]:
|
||||
"""Resolve tag ids to their display names."""
|
||||
raw_tags = entry.get("tags")
|
||||
if not isinstance(raw_tags, list):
|
||||
return []
|
||||
resolved: List[str] = []
|
||||
for tag_id in raw_tags:
|
||||
if not isinstance(tag_id, str) or not tag_id:
|
||||
continue
|
||||
tag = self._tags.get(tag_id)
|
||||
name = tag.get("name") if isinstance(tag, dict) else None
|
||||
resolved.append(name if isinstance(name, str) and name else tag_id)
|
||||
return resolved
|
||||
|
||||
def _resolve_architecture_name(self, entry: Dict[str, Any]) -> str:
|
||||
"""Resolve the architecture id to its display name."""
|
||||
arch_id = entry.get("architecture")
|
||||
if not isinstance(arch_id, str) or not arch_id:
|
||||
return ""
|
||||
arch = self._architectures.get(arch_id)
|
||||
if isinstance(arch, dict):
|
||||
name = arch.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
return arch_id
|
||||
|
||||
@staticmethod
|
||||
def _resource_format(resource: Dict[str, Any]) -> str:
|
||||
"""Map an OpenModelDB resource type to a CivitAI file metadata format."""
|
||||
resource_type = str(resource.get("type") or "").lower()
|
||||
if resource_type == "safetensors":
|
||||
return "SafeTensor"
|
||||
if resource_type in ("pth", "pt", "ckpt"):
|
||||
return "PickleTensor"
|
||||
return "Other"
|
||||
|
||||
def _to_civitai_version(
|
||||
self,
|
||||
model_id: str,
|
||||
entry: Dict[str, Any],
|
||||
matched_resource: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Map an OpenModelDB catalogue entry to a CivitAI-shaped version dict.
|
||||
|
||||
Follows the same contract as the CivArchive/SQLite providers so the
|
||||
metadata sync service can merge it unchanged. Numeric ``id``/``modelId``
|
||||
are deliberately omitted: OpenModelDB ids are strings, and consumers
|
||||
treat a missing ``modelId`` as "not a CivitAI model" (no CivitAI page
|
||||
link, no update checks).
|
||||
"""
|
||||
author_display, author_ids = self._resolve_authors(entry)
|
||||
tags = self._resolve_tags(entry)
|
||||
architecture_id = entry.get("architecture")
|
||||
architecture_name = self._resolve_architecture_name(entry)
|
||||
description = entry.get("description")
|
||||
license_name = entry.get("license")
|
||||
page_url = f"{OPENMODELDB_SITE_BASE}/models/{model_id}"
|
||||
|
||||
files: List[Dict[str, Any]] = []
|
||||
resources = entry.get("resources")
|
||||
for resource in resources if isinstance(resources, list) else []:
|
||||
if not isinstance(resource, dict):
|
||||
continue
|
||||
# The displayable download URL prefers a direct-bytes mirror when
|
||||
# one exists; the filename is derived (never the raw URL basename,
|
||||
# which mediafire-style mirrors leave as "file").
|
||||
download_url = self.direct_url(resource) or self.primary_url(resource)
|
||||
sha256 = resource.get("sha256")
|
||||
size_bytes = resource.get("size")
|
||||
files.append(
|
||||
{
|
||||
"name": self.resource_filename(model_id, resource),
|
||||
"type": "Model",
|
||||
"sizeKB": (size_bytes / 1024.0)
|
||||
if isinstance(size_bytes, (int, float))
|
||||
else 0,
|
||||
"downloadUrl": download_url,
|
||||
"primary": resource is matched_resource,
|
||||
"hashes": {"SHA256": str(sha256).upper()} if sha256 else {},
|
||||
"metadata": {"format": self._resource_format(resource)},
|
||||
}
|
||||
)
|
||||
|
||||
images: List[Dict[str, Any]] = []
|
||||
# The model-level thumbnail is the site's own preview pick and larger
|
||||
# than the per-image small thumbs; the card preview derives from
|
||||
# images[0], so it leads the list.
|
||||
lead = self._model_preview_url(entry)
|
||||
if lead:
|
||||
images.append({"url": lead, "nsfwLevel": 1, "type": "image"})
|
||||
raw_images = entry.get("images")
|
||||
for image in raw_images if isinstance(raw_images, list) else []:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
paired = image.get("type") == "paired"
|
||||
# Paired entries show the upscaled (SR) result as the preview.
|
||||
url = self._paired_display_url(image) if paired else image.get("url")
|
||||
if not isinstance(url, str) or not url:
|
||||
continue
|
||||
if any(existing["url"] == url for existing in images):
|
||||
continue
|
||||
mapped: Dict[str, Any] = {"url": url, "nsfwLevel": 1, "type": "image"}
|
||||
thumbnail = image.get("thumbnail")
|
||||
if isinstance(thumbnail, str) and thumbnail:
|
||||
thumbnail_url = self._absolutize(thumbnail)
|
||||
if thumbnail_url != url:
|
||||
mapped["thumbnailUrl"] = thumbnail_url
|
||||
meta: Dict[str, Any] = {}
|
||||
if paired:
|
||||
comparison = image.get("SR") or image.get("LR")
|
||||
if (
|
||||
isinstance(comparison, str)
|
||||
and comparison
|
||||
and comparison != url
|
||||
and _is_ephemeral_viewer_url(comparison)
|
||||
):
|
||||
# Ephemeral imgdiff viewer session, kept for reference
|
||||
# only — it 404s outside the session and is never
|
||||
# displayable.
|
||||
meta["comparisonUrl"] = comparison
|
||||
caption = image.get("caption")
|
||||
if isinstance(caption, str) and caption:
|
||||
meta["caption"] = caption
|
||||
if meta:
|
||||
mapped["meta"] = meta
|
||||
images.append(mapped)
|
||||
|
||||
return {
|
||||
"name": entry.get("name") or model_id,
|
||||
# Upscalers are not tied to a diffusion base model.
|
||||
"baseModel": "Other",
|
||||
"description": description or "",
|
||||
"publishedAt": entry.get("date"),
|
||||
"trainedWords": [],
|
||||
"model": {
|
||||
"name": entry.get("name") or model_id,
|
||||
"type": "Upscaler",
|
||||
"nsfw": False,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"license": license_name or "",
|
||||
},
|
||||
"creator": {"username": author_display, "image": None},
|
||||
"files": files,
|
||||
"images": images,
|
||||
"source": METADATA_SOURCE_VALUE,
|
||||
# OpenModelDB-native provenance, kept inside the persisted payload
|
||||
# so the UI can link to the model page in a later phase.
|
||||
"openmodeldb": {
|
||||
"id": model_id,
|
||||
"url": page_url,
|
||||
"authors": author_ids,
|
||||
"architecture": architecture_id or "",
|
||||
"architectureName": architecture_name,
|
||||
"scale": entry.get("scale"),
|
||||
"inputChannels": entry.get("inputChannels"),
|
||||
"outputChannels": entry.get("outputChannels"),
|
||||
"size": entry.get("size") or [],
|
||||
"license": license_name or "",
|
||||
"date": entry.get("date"),
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Disk cache
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _cache_dir(self) -> str:
|
||||
base = self._cache_dir_override or os.path.join(
|
||||
get_cache_base_dir(), "openmodeldb"
|
||||
)
|
||||
os.makedirs(base, exist_ok=True)
|
||||
return base
|
||||
|
||||
def _dump_path(self, name: str) -> str:
|
||||
return os.path.join(self._cache_dir(), f"{name}.json")
|
||||
|
||||
def _meta_path(self) -> str:
|
||||
return os.path.join(self._cache_dir(), _META_FILENAME)
|
||||
|
||||
def _read_meta(self) -> Dict[str, Any]:
|
||||
try:
|
||||
with open(self._meta_path(), "r", encoding="utf-8") as handle:
|
||||
meta = json.load(handle)
|
||||
return meta if isinstance(meta, dict) else {}
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read OpenModelDB cache meta: %s", exc)
|
||||
return {}
|
||||
|
||||
def _read_dump_file(self, path: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to read OpenModelDB cache %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
def _write_cache(
|
||||
self,
|
||||
payloads: Dict[str, Dict[str, Any]],
|
||||
etags: Dict[str, str],
|
||||
last_modified: Dict[str, str],
|
||||
) -> None:
|
||||
for name, payload in payloads.items():
|
||||
path = self._dump_path(name)
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write OpenModelDB cache %s: %s", path, exc)
|
||||
|
||||
meta = {
|
||||
"fetched_at": time.time(),
|
||||
"etags": etags,
|
||||
"last_modified": last_modified,
|
||||
}
|
||||
try:
|
||||
with open(self._meta_path(), "w", encoding="utf-8") as handle:
|
||||
json.dump(meta, handle, indent=2)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write OpenModelDB cache meta: %s", exc)
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
from ..utils.models import OtherModelMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.sidecar_paths import get_preview_dir, is_centralized
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
@@ -72,10 +73,9 @@ class OtherScanner(ModelScanner):
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large files; record the checked state at creation time ("" =
|
||||
@@ -333,6 +333,11 @@ class OtherScanner(ModelScanner):
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for other-model metadata files with pending hash status."""
|
||||
# Centralized mode stores sidecars in the mirror tree, not next to the
|
||||
# models; walk the mirror instead of the model folders.
|
||||
if is_centralized():
|
||||
return self._find_pending_models_in_sidecar_mirror()
|
||||
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
|
||||
@@ -38,6 +38,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_sidecar_dir
|
||||
from ..utils import settings_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -669,13 +670,22 @@ class PendingDeleteService:
|
||||
"""Enumerate existing artifacts exactly like delete_model_artifacts."""
|
||||
main_extension = ".safetensors" if main_extension is None else main_extension
|
||||
main_file = f"{file_name}{main_extension}" if main_extension else file_name
|
||||
patterns = [main_file, f"{file_name}.metadata.json"]
|
||||
model_path = os.path.join(target_dir, main_file)
|
||||
|
||||
artifacts: List[str] = []
|
||||
main_path = os.path.abspath(model_path)
|
||||
if os.path.exists(main_path):
|
||||
artifacts.append(main_path)
|
||||
|
||||
# Sidecars/previews live in the sidecar dir (the model's own dir in
|
||||
# alongside mode, the centralized mirror otherwise).
|
||||
sidecar_dir = get_sidecar_dir(model_path)
|
||||
patterns = [os.path.basename(get_metadata_path(model_path))]
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
patterns.append(f"{file_name}{ext}")
|
||||
|
||||
artifacts: List[str] = []
|
||||
for pattern in patterns:
|
||||
path = os.path.abspath(os.path.join(target_dir, pattern))
|
||||
path = os.path.abspath(os.path.join(sidecar_dir, pattern))
|
||||
if os.path.exists(path):
|
||||
artifacts.append(path)
|
||||
return artifacts
|
||||
@@ -694,7 +704,9 @@ class PendingDeleteService:
|
||||
"""
|
||||
for original_path in artifacts:
|
||||
staged_path = os.path.join(batch_dir, os.path.basename(original_path))
|
||||
os.rename(original_path, staged_path)
|
||||
# EXDEV-tolerant: centralized sidecars may live on a different
|
||||
# filesystem than the staging batch dir under the model root.
|
||||
self._restore_file(original_path, staged_path)
|
||||
staged_pairs.append(
|
||||
{
|
||||
"staged": os.path.abspath(staged_path),
|
||||
@@ -736,13 +748,14 @@ class PendingDeleteService:
|
||||
return staged_pairs
|
||||
|
||||
def _restore_file(self, staged_path: str, original_path: str) -> None:
|
||||
"""Restore a staged file to its original path, tolerating EXDEV.
|
||||
"""Move a file between staging and library paths, tolerating EXDEV.
|
||||
|
||||
``os.rename`` is atomic and preferred (model staging and most recipe
|
||||
restores are same-volume). Recipe staging copies into the settings-dir
|
||||
staging parent, which may live on a DIFFERENT filesystem than the
|
||||
recipes dir; rename then raises EXDEV. Fall back to ``shutil.copy2`` +
|
||||
``os.remove`` so the bytes are restored and the staged copy removed.
|
||||
staging parent, and centralized sidecars live under the configured
|
||||
sidecar root; both may live on a DIFFERENT filesystem than the target
|
||||
dir, so rename can raise EXDEV. Fall back to ``shutil.copy2`` +
|
||||
``os.remove`` so the bytes are moved and the source copy removed.
|
||||
"""
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
@@ -764,7 +777,10 @@ class PendingDeleteService:
|
||||
if not os.path.exists(staged_path):
|
||||
continue
|
||||
try:
|
||||
os.rename(staged_path, original_path)
|
||||
# EXDEV-tolerant: centralized sidecars may have been copied
|
||||
# across filesystems into staging, so plain os.rename would
|
||||
# fail here and strand the only copy.
|
||||
self._restore_file(staged_path, original_path)
|
||||
except OSError as exc: # pragma: no cover - best-effort rollback
|
||||
logger.warning(
|
||||
"Failed to roll back staged file %s -> %s: %s",
|
||||
|
||||
@@ -10,6 +10,7 @@ from urllib.parse import urlparse
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -63,6 +64,9 @@ class PreviewAssetService:
|
||||
|
||||
base_name = os.path.splitext(os.path.splitext(os.path.basename(metadata_path))[0])[0]
|
||||
preview_dir = os.path.dirname(metadata_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own
|
||||
# directory in alongside mode).
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
is_video = first_preview.get("type") == "video"
|
||||
preview_url = first_preview.get("url")
|
||||
|
||||
@@ -159,7 +163,10 @@ class PreviewAssetService:
|
||||
"""Replace an existing preview asset for a model."""
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||
folder = os.path.dirname(model_path)
|
||||
folder = get_preview_dir(model_path)
|
||||
# Centralized mirrors may not exist yet (unlike the model's own
|
||||
# directory in alongside mode).
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
extension, optimized_data = await self._convert_preview(
|
||||
preview_data, content_type, original_filename
|
||||
@@ -179,7 +186,7 @@ class PreviewAssetService:
|
||||
with open(preview_path, "wb") as handle:
|
||||
handle.write(optimized_data)
|
||||
|
||||
metadata_path = os.path.splitext(model_path)[0] + ".metadata.json"
|
||||
metadata_path = get_metadata_path(model_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
metadata["preview_url"] = preview_path
|
||||
metadata["preview_nsfw_level"] = nsfw_level
|
||||
|
||||
@@ -2676,23 +2676,11 @@ 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:
|
||||
@@ -3750,6 +3738,12 @@ class RecipeScanner:
|
||||
if self._compute_availability_statuses(item) & selected
|
||||
]
|
||||
|
||||
# Filter by embedded workflow presence
|
||||
if filters.get("has_workflow"):
|
||||
filtered_data = [
|
||||
item for item in filtered_data if item.get("has_workflow")
|
||||
]
|
||||
|
||||
# Apply sorting if not already handled by pre-sorted cache
|
||||
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
|
||||
field, order = (sort_by.split(":") + ["desc"])[:2]
|
||||
@@ -4580,14 +4574,64 @@ class RecipeScanner:
|
||||
|
||||
return syntax_parts
|
||||
|
||||
async def build_lora_hash_index(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Build a one-shot lowercase-LoRA-hash → recipes index.
|
||||
|
||||
Scans the recipe cache exactly once (O(recipes × loras)) and returns
|
||||
a mapping of lowercase lora ``hash`` to the list of recipe dicts
|
||||
containing it. Bulk rename loops pass this index to
|
||||
:meth:`update_lora_filename_by_hash` so per-file lookups are O(1)
|
||||
instead of rescanning every recipe for each renamed LoRA.
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
index: Dict[str, List[Dict[str, Any]]] = {}
|
||||
if not cache or not cache.raw_data:
|
||||
return index
|
||||
|
||||
for recipe in cache.raw_data:
|
||||
loras = recipe.get("loras", [])
|
||||
if not isinstance(loras, list):
|
||||
continue
|
||||
for lora in loras:
|
||||
if not isinstance(lora, dict):
|
||||
continue
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
if hash_value:
|
||||
index.setdefault(hash_value, []).append(recipe)
|
||||
return index
|
||||
|
||||
async def finalize_bulk_filename_updates(self) -> None:
|
||||
"""Run once after a bulk rename session that deferred maintenance.
|
||||
|
||||
Refreshes folder metadata and schedules a single re-sort. Filename-only
|
||||
renames never change recipe folders, so the deferred refresh is
|
||||
redundant but cheap; skipping it per file is what makes bulk renames
|
||||
O(1)-per-file.
|
||||
"""
|
||||
if self._cache is None:
|
||||
return
|
||||
self._schedule_resort()
|
||||
|
||||
async def update_lora_filename_by_hash(
|
||||
self, hash_value: str, new_file_name: str
|
||||
self,
|
||||
hash_value: str,
|
||||
new_file_name: str,
|
||||
*,
|
||||
hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None,
|
||||
defer_maintenance: bool = False,
|
||||
) -> Tuple[int, int]:
|
||||
"""Update file_name in all recipes that contain a LoRA with the specified hash.
|
||||
|
||||
Args:
|
||||
hash_value: The SHA256 hash value of the LoRA
|
||||
new_file_name: The new file_name to set
|
||||
hash_index: Optional prebuilt index from
|
||||
:meth:`build_lora_hash_index`. When given, the O(recipes)
|
||||
cache scan (and its folder-metadata walk) is skipped and the
|
||||
affected recipes are looked up directly — the bulk rename path.
|
||||
defer_maintenance: When True, skip the folder-metadata refresh and
|
||||
resort scheduling. The caller MUST run
|
||||
:meth:`finalize_bulk_filename_updates` exactly once afterwards.
|
||||
|
||||
Returns:
|
||||
Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache)
|
||||
@@ -4598,17 +4642,21 @@ class RecipeScanner:
|
||||
# Always use lowercase hash for consistency
|
||||
hash_value = hash_value.lower()
|
||||
|
||||
# Get cache
|
||||
cache = await self.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return 0, 0
|
||||
if hash_index is not None:
|
||||
candidate_recipes = hash_index.get(hash_value, [])
|
||||
else:
|
||||
# Get cache
|
||||
cache = await self.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return 0, 0
|
||||
candidate_recipes = cache.raw_data
|
||||
|
||||
file_updated_count = 0
|
||||
cache_updated_count = 0
|
||||
|
||||
# Find recipes that need updating from the cache
|
||||
# Find recipes that need updating
|
||||
recipes_to_update = []
|
||||
for recipe in cache.raw_data:
|
||||
for recipe in candidate_recipes:
|
||||
loras = recipe.get("loras", [])
|
||||
if not isinstance(loras, list):
|
||||
continue
|
||||
@@ -4654,7 +4702,9 @@ class RecipeScanner:
|
||||
# We don't necessarily need to resort because LoRA file_name isn't a sort key,
|
||||
# but we might want to schedule a resort if we're paranoid or if searching relies on sorted state.
|
||||
# Given it's a rename of a dependency, search results might change if searching by LoRA name.
|
||||
self._schedule_resort()
|
||||
# Bulk callers defer this to a single finalize_bulk_filename_updates() call.
|
||||
if not defer_maintenance:
|
||||
self._schedule_resort()
|
||||
|
||||
return file_updated_count, cache_updated_count
|
||||
|
||||
|
||||
@@ -117,6 +117,10 @@ class RecipeAnalysisService:
|
||||
image_info: Optional[dict[str, Any]] = None
|
||||
is_video = False
|
||||
extension = ".jpg" # Default
|
||||
# Workflow recovered from the image. CivitAI's optimized renditions are
|
||||
# re-encoded and carry no metadata, so for those the workflow only
|
||||
# exists in the original rendition, fetched below for EXIF extraction.
|
||||
recovered_workflow: Optional[str] = None
|
||||
# Diagnostics collected during analysis; surfaced in the payload so
|
||||
# callers can persist an import_info block explaining empty LoRA lists.
|
||||
diagnostics: dict[str, Any] = {"channel": "url"}
|
||||
@@ -238,6 +242,9 @@ class RecipeAnalysisService:
|
||||
exif_metadata = await asyncio.to_thread(
|
||||
self._exif_utils.extract_image_metadata, temp_path
|
||||
)
|
||||
recovered_workflow = await asyncio.to_thread(
|
||||
self._read_embedded_workflow, temp_path
|
||||
)
|
||||
|
||||
# Fallback: try the original (non-optimized) image for EXIF data
|
||||
if not exif_metadata and civitai_image_id and image_info:
|
||||
@@ -255,6 +262,16 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata,
|
||||
orig_temp_path,
|
||||
)
|
||||
# The original is also the only place a ComfyUI
|
||||
# workflow survives; carry it so the save step can
|
||||
# embed it even though the stored preview stays the
|
||||
# small, metadata-free optimized rendition.
|
||||
recovered_workflow = (
|
||||
await asyncio.to_thread(
|
||||
self._read_embedded_workflow, orig_temp_path
|
||||
)
|
||||
or recovered_workflow
|
||||
)
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
@@ -358,6 +375,8 @@ class RecipeAnalysisService:
|
||||
|
||||
diagnostics["is_video"] = is_video
|
||||
result.payload["diagnostics"] = diagnostics
|
||||
if recovered_workflow:
|
||||
result.payload["workflow"] = recovered_workflow
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -545,6 +564,25 @@ class RecipeAnalysisService:
|
||||
if not success:
|
||||
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
|
||||
|
||||
def _read_embedded_workflow(self, image_path: Optional[str]) -> Optional[str]:
|
||||
"""Return a ComfyUI workflow embedded in ``image_path``, if any.
|
||||
|
||||
The raw metadata string extractor stops at the generation parameters
|
||||
(``prompt``/``parameters``), so the UI-format workflow has to be read
|
||||
through the structured metadata reader. Failures map to ``None``.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return None
|
||||
try:
|
||||
metadata = self._exif_utils._load_structured_metadata(image_path)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Failed to read embedded workflow from %s: %s", image_path, exc
|
||||
)
|
||||
return None
|
||||
workflow = metadata.get("workflow") if isinstance(metadata, dict) else None
|
||||
return workflow if isinstance(workflow, str) and workflow else None
|
||||
|
||||
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
|
||||
payload: dict[str, Any] = {
|
||||
"error": "No metadata found in this image",
|
||||
|
||||
@@ -18,6 +18,7 @@ from ...utils.base_model import (
|
||||
RELATION_INCOMPATIBLE,
|
||||
base_model_relation,
|
||||
)
|
||||
from ...utils.constants import MAX_WORKFLOW_EMBED_BYTES
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ..pending_delete_service import get_pending_delete_service
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
@@ -73,6 +74,11 @@ class RecipePersistenceService:
|
||||
byte-level EXIF update that leaves the pixels untouched). Used
|
||||
by local re-import, where the source is the recipe's own
|
||||
already-optimized preview image.
|
||||
|
||||
``metadata`` may carry a ``workflow`` entry (JSON string, dict or
|
||||
list) recovered from the source's original rendition; it is embedded
|
||||
into the stored image so the recipe reports ``has_workflow`` and can
|
||||
send the workflow back to ComfyUI.
|
||||
"""
|
||||
|
||||
missing_fields = []
|
||||
@@ -87,6 +93,13 @@ class RecipePersistenceService:
|
||||
|
||||
assert metadata is not None
|
||||
|
||||
# A workflow recovered from a higher-fidelity source (CivitAI's
|
||||
# original rendition — its optimized preview is re-encoded and carries
|
||||
# no metadata) travels as data instead of as image bytes. It is
|
||||
# embedded below so ``has_workflow`` and the "send workflow to ComfyUI"
|
||||
# action work for imports whose preview pixels are metadata-free.
|
||||
workflow = metadata.get("workflow")
|
||||
|
||||
resolved_image_bytes = self._resolve_image_bytes(image_bytes, image_base64)
|
||||
recipes_dir = target_dir or recipe_scanner.recipes_dir
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
@@ -108,6 +121,7 @@ class RecipePersistenceService:
|
||||
format="webp",
|
||||
quality=85,
|
||||
preserve_metadata=True,
|
||||
workflow=workflow,
|
||||
)
|
||||
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
@@ -116,6 +130,12 @@ class RecipePersistenceService:
|
||||
with open(normalized_image_path, "wb") as file_obj:
|
||||
file_obj.write(optimized_image)
|
||||
|
||||
# The optimization branch above embeds the workflow while re-encoding;
|
||||
# the verbatim (skip_optimize) branch still needs it added, and this is
|
||||
# also the safety net when re-encoding dropped it.
|
||||
if workflow and not is_video:
|
||||
self._exif_utils.embed_workflow(normalized_image_path, workflow)
|
||||
|
||||
current_time = time.time()
|
||||
loras_data = [self._normalise_lora_entry(lora) for lora in (metadata.get("loras") or [])]
|
||||
checkpoint_entry = self._sanitize_checkpoint_entry(self._extract_checkpoint_entry(metadata))
|
||||
@@ -855,8 +875,15 @@ class RecipePersistenceService:
|
||||
recipe_scanner,
|
||||
metadata: dict[str, Any],
|
||||
image_bytes: bytes,
|
||||
workflow: Any = None,
|
||||
) -> PersistenceResult:
|
||||
"""Save a recipe constructed from widget metadata."""
|
||||
"""Save a recipe constructed from widget metadata.
|
||||
|
||||
``workflow`` is the caller's ComfyUI graph (UI or API format) to embed
|
||||
in the stored preview. Embedding is opt-in because the graph is by far
|
||||
the largest metadata field and its widget values may contain sensitive
|
||||
data; an oversized graph is dropped rather than inflating the preview.
|
||||
"""
|
||||
|
||||
if not metadata:
|
||||
raise RecipeValidationError("No generation metadata found")
|
||||
@@ -865,12 +892,25 @@ class RecipePersistenceService:
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
|
||||
recipe_id = str(uuid.uuid4())
|
||||
|
||||
workflow_json = self._exif_utils.normalise_workflow(workflow)
|
||||
workflow_skipped: Optional[str] = None
|
||||
if workflow_json and len(workflow_json.encode("utf-8")) > MAX_WORKFLOW_EMBED_BYTES:
|
||||
self._logger.warning(
|
||||
"Widget workflow is %d bytes (limit %d); saving recipe without it",
|
||||
len(workflow_json),
|
||||
MAX_WORKFLOW_EMBED_BYTES,
|
||||
)
|
||||
workflow_json = None
|
||||
workflow_skipped = "too_large"
|
||||
|
||||
optimized_image, extension = self._exif_utils.optimize_image(
|
||||
image_data=image_bytes,
|
||||
target_width=self._card_preview_width,
|
||||
format="webp",
|
||||
quality=85,
|
||||
preserve_metadata=True,
|
||||
workflow=workflow_json,
|
||||
)
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
image_path = os.path.join(recipes_dir, image_filename)
|
||||
@@ -924,9 +964,9 @@ class RecipePersistenceService:
|
||||
if key not in ["checkpoint", "loras"]
|
||||
},
|
||||
"loras_stack": lora_stack,
|
||||
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
|
||||
# embedded metadata chunks, so a workflow can never be present.
|
||||
"has_workflow": False,
|
||||
# Set by detection below: the workflow is embedded during
|
||||
# re-encoding only when the caller opted in and it fit the cap.
|
||||
"has_workflow": self._detect_has_workflow(image_path),
|
||||
# Widget saves read LoRAs straight from the current workflow; an
|
||||
# empty list means the workflow used no LoRAs.
|
||||
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
|
||||
@@ -942,15 +982,17 @@ class RecipePersistenceService:
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
await recipe_scanner.add_recipe(recipe_data)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"image_path": image_path,
|
||||
"json_path": json_path,
|
||||
"recipe_name": recipe_name,
|
||||
}
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"image_path": image_path,
|
||||
"json_path": json_path,
|
||||
"recipe_name": recipe_name,
|
||||
"has_workflow": recipe_data["has_workflow"],
|
||||
}
|
||||
if workflow_skipped:
|
||||
payload["workflow_skipped"] = workflow_skipped
|
||||
return PersistenceResult(payload)
|
||||
|
||||
# Helper methods ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -251,7 +251,28 @@ class ServiceRegistry:
|
||||
cls._services[service_name] = client
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return client
|
||||
|
||||
|
||||
@classmethod
|
||||
async def get_openmodeldb_client(cls):
|
||||
"""Get or create OpenModelDB client instance"""
|
||||
service_name = "openmodeldb_client"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
# Double-check after acquiring lock
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from .openmodeldb_client import OpenModelDBClient
|
||||
|
||||
client = await OpenModelDBClient.get_instance()
|
||||
cls._services[service_name] = client
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
async def get_download_manager(cls):
|
||||
"""Get or create Download manager instance"""
|
||||
|
||||
@@ -79,6 +79,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"dismissed_banners": [],
|
||||
"enable_metadata_archive_db": False,
|
||||
"enable_civarchive_api": True,
|
||||
# OpenModelDB supplies read-only metadata for upscaler models (the "other"
|
||||
# page's upscaler sub_type) via hash matching against its bulk catalogue.
|
||||
"enable_openmodeldb_api": True,
|
||||
"metadata_provider_order": "civitai_archive_sqlite",
|
||||
"rate_limit_gate_enabled": True,
|
||||
"rate_limit_max_wait_seconds": 300,
|
||||
@@ -99,6 +102,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"enable_other_models": False,
|
||||
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
|
||||
"recipes_path": "",
|
||||
"sidecar_storage_mode": "alongside",
|
||||
"sidecar_storage_path": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
"download_filename_templates": {},
|
||||
@@ -116,6 +121,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"display_density": "default",
|
||||
"recipes_layout": "grid",
|
||||
"card_info_display": "always",
|
||||
"showcase_layout": "gallery",
|
||||
"include_trigger_words": False,
|
||||
"compact_mode": False,
|
||||
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
|
||||
@@ -124,10 +130,19 @@ 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,
|
||||
"download_skip_base_models": [],
|
||||
# Routing target for checkpoint downloads whose baseModel is neither a
|
||||
# known diffusion model nor a known full checkpoint (CHECKPOINT_BASE_MODELS).
|
||||
"unknown_base_model_routing": "diffusion_model",
|
||||
"backup_auto_enabled": True,
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
@@ -568,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", {})
|
||||
@@ -1616,9 +1664,40 @@ class SettingsManager:
|
||||
|
||||
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_sidecar_storage_mode(value: Any) -> str:
|
||||
"""Return a valid sidecar storage mode, falling back to ``alongside``."""
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("alongside", "centralized"):
|
||||
return normalized
|
||||
return "alongside"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_unknown_base_model_routing(value: Any) -> str:
|
||||
"""Return a valid unknown-base-model routing target, falling back to ``diffusion_model``."""
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in ("diffusion_model", "checkpoint"):
|
||||
return normalized
|
||||
return "diffusion_model"
|
||||
|
||||
def _refresh_sidecar_storage_config(self) -> None:
|
||||
"""Rebuild dependent config state after sidecar storage settings change."""
|
||||
|
||||
try:
|
||||
from ..config import config # Local import to avoid circular dependency
|
||||
|
||||
config.refresh_preview_roots()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug(
|
||||
"Failed to refresh config after sidecar storage change: %s", exc
|
||||
)
|
||||
|
||||
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
|
||||
"""Resolve the effective recipes directory for the active library."""
|
||||
|
||||
normalized_custom = self._normalize_recipes_path_value(
|
||||
self.settings.get("recipes_path", "")
|
||||
if recipes_path is None
|
||||
@@ -1815,6 +1894,12 @@ class SettingsManager:
|
||||
target_recipes_dir = self._get_effective_recipes_dir(value)
|
||||
self._validate_recipes_storage_path(target_recipes_dir)
|
||||
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
|
||||
elif key == "sidecar_storage_mode":
|
||||
value = self._normalize_sidecar_storage_mode(value)
|
||||
elif key == "unknown_base_model_routing":
|
||||
value = self._normalize_unknown_base_model_routing(value)
|
||||
elif key == "sidecar_storage_path":
|
||||
value = self._normalize_recipes_path_value(value)
|
||||
self.settings[key] = value
|
||||
portable_switch_pending = False
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
@@ -1845,6 +1930,8 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
if key == "recipes_path":
|
||||
self._notify_library_change(self.get_active_library_name())
|
||||
if key in ("sidecar_storage_mode", "sidecar_storage_path"):
|
||||
self._refresh_sidecar_storage_config()
|
||||
if key in ("enable_other_models", "enabled_other_sub_types"):
|
||||
self._apply_other_model_settings_change()
|
||||
if portable_switch_pending:
|
||||
@@ -2328,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", {})
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from typing import Awaitable, Callable, Dict, List, Sequence, Tuple
|
||||
|
||||
from ..utils.sidecar_paths import get_metadata_path
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
|
||||
|
||||
@@ -24,8 +23,7 @@ class TagUpdateService:
|
||||
update_cache: Callable[[str, str, Dict[str, object]], Awaitable[bool]],
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Add tags to a metadata entry and return updated tags and auto_tags."""
|
||||
base, _ = os.path.splitext(file_path)
|
||||
metadata_path = f"{base}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await metadata_loader(metadata_path)
|
||||
|
||||
raw_tags = metadata.get("tags", [])
|
||||
|
||||
@@ -21,6 +21,10 @@ from .example_images import (
|
||||
ImportExampleImagesValidationError,
|
||||
)
|
||||
from .filename_template_use_case import FilenameTemplateUseCase
|
||||
from .sidecar_migration_use_case import (
|
||||
SidecarMigrationProgressReporter,
|
||||
SidecarMigrationUseCase,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AutoOrganizeInProgressError",
|
||||
@@ -36,4 +40,6 @@ __all__ = [
|
||||
"ImportExampleImagesUseCase",
|
||||
"ImportExampleImagesValidationError",
|
||||
"FilenameTemplateUseCase",
|
||||
"SidecarMigrationProgressReporter",
|
||||
"SidecarMigrationUseCase",
|
||||
]
|
||||
|
||||
@@ -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"):
|
||||
payload["failures"] = failures
|
||||
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:
|
||||
|
||||
@@ -29,6 +29,7 @@ class ImportExampleImagesUseCase:
|
||||
|
||||
async def execute(self, request: web.Request) -> Dict[str, Any]:
|
||||
model_hash: str | None = None
|
||||
model_path: str | None = None
|
||||
files_to_import: List[str] = []
|
||||
temp_files: List[str] = []
|
||||
|
||||
@@ -40,6 +41,8 @@ class ImportExampleImagesUseCase:
|
||||
first_field = cast(BodyPartReader, first_field_raw) if first_field_raw is not None else None
|
||||
if first_field and first_field.name == "model_hash":
|
||||
model_hash = await first_field.text()
|
||||
elif first_field and first_field.name == "model_path":
|
||||
model_path = await first_field.text()
|
||||
else:
|
||||
# Support clients that send files first and hash later
|
||||
if first_field is not None:
|
||||
@@ -49,13 +52,22 @@ class ImportExampleImagesUseCase:
|
||||
field = cast(BodyPartReader, raw_field)
|
||||
if field.name == "model_hash" and not model_hash:
|
||||
model_hash = await field.text()
|
||||
elif field.name == "model_path" and not model_path:
|
||||
model_path = await field.text()
|
||||
elif field.name == "files":
|
||||
await self._collect_upload_file(field, files_to_import, temp_files)
|
||||
else:
|
||||
data = await request.json()
|
||||
model_hash = data.get("model_hash")
|
||||
model_path = data.get("model_path")
|
||||
files_to_import = list(data.get("file_paths", []))
|
||||
|
||||
# Models with a deferred hash (checkpoints, Other) send an empty
|
||||
# model_hash; locate them by file path and compute the hash on
|
||||
# demand, since example-image folders are keyed by hash.
|
||||
if not model_hash and model_path:
|
||||
model_hash = await self._processor.resolve_hash_for_file_path(model_path)
|
||||
|
||||
if not model_hash:
|
||||
raise ImportExampleImagesValidationError("Missing model_hash parameter")
|
||||
result = await self._processor.import_images(model_hash, files_to_import)
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
|
||||
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ...utils.sidecar_paths import get_metadata_path
|
||||
from ...utils.utils import calculate_filename_for_model
|
||||
from ..model_file_service import AutoOrganizeResult, ProgressCallback
|
||||
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||
@@ -32,7 +33,9 @@ class FilenameTemplateUseCase:
|
||||
An empty template restores the recorded original filename instead of
|
||||
rendering a template. Shares the auto-organize lock (and its in-progress
|
||||
error) so a bulk rename never runs concurrently with an auto-organize
|
||||
operation.
|
||||
operation. The whole loop runs inside a bulk rename session so cache
|
||||
persist/resort and recipe maintenance happen once at the end instead of
|
||||
per renamed file.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -105,23 +108,24 @@ class FilenameTemplateUseCase:
|
||||
|
||||
await self._emit_progress(progress_callback, result, "started")
|
||||
|
||||
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||
if self._scanner.is_cancelled():
|
||||
logger.info(
|
||||
"Filename template apply cancelled for %s", self._model_type
|
||||
)
|
||||
break
|
||||
|
||||
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
|
||||
for model in batch:
|
||||
async with self._lifecycle_service.bulk_rename_session() as bulk_context:
|
||||
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
|
||||
if self._scanner.is_cancelled():
|
||||
logger.info(
|
||||
"Filename template apply cancelled for %s", self._model_type
|
||||
)
|
||||
break
|
||||
await self._process_model(model, template, result)
|
||||
result.processed += 1
|
||||
|
||||
await self._emit_progress(progress_callback, result, "processing")
|
||||
# Yield between batches so the server stays responsive.
|
||||
await asyncio.sleep(0.1)
|
||||
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
|
||||
for model in batch:
|
||||
if self._scanner.is_cancelled():
|
||||
break
|
||||
await self._process_model(model, template, result, bulk_context)
|
||||
result.processed += 1
|
||||
|
||||
await self._emit_progress(progress_callback, result, "processing")
|
||||
# Yield between batches so the server stays responsive.
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if self._scanner.is_cancelled():
|
||||
result.status = "cancelled"
|
||||
@@ -149,6 +153,7 @@ class FilenameTemplateUseCase:
|
||||
model: Dict[str, Any],
|
||||
template: str,
|
||||
result: AutoOrganizeResult,
|
||||
bulk_context: Any = None,
|
||||
) -> None:
|
||||
model_name = model.get("model_name", "Unknown")
|
||||
try:
|
||||
@@ -176,7 +181,7 @@ class FilenameTemplateUseCase:
|
||||
return
|
||||
|
||||
await self._lifecycle_service.rename_model(
|
||||
file_path=file_path, new_file_name=new_stem
|
||||
file_path=file_path, new_file_name=new_stem, bulk_context=bulk_context
|
||||
)
|
||||
result.success_count += 1
|
||||
|
||||
@@ -200,7 +205,7 @@ class FilenameTemplateUseCase:
|
||||
sidecar or no ``original_file_name`` entry exists (models never
|
||||
renamed, or renamed before the recording shipped).
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
metadata = await self._metadata_loader(metadata_path)
|
||||
original = metadata.get("original_file_name")
|
||||
if not isinstance(original, str):
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
"""Use case migrating sidecar metadata and previews between storage layouts.
|
||||
|
||||
Two storage layouts exist (see :mod:`py.utils.sidecar_paths`):
|
||||
|
||||
- ``alongside``: ``<model_dir>/<name>.metadata.json`` and preview files live
|
||||
next to the model file.
|
||||
- ``centralized``: the same files live under the configured sidecar root,
|
||||
mirroring each model root's directory structure under a per-root identity
|
||||
component (see :func:`py.utils.sidecar_paths.root_mirror_component`).
|
||||
|
||||
This use case moves the ``.metadata.json`` sidecar and preview files for every
|
||||
known model from one layout to the other. Model files themselves NEVER move.
|
||||
Paths inside the moved sidecar (``file_path``, ``file_name``, ``preview_url``)
|
||||
are rewritten the same way :meth:`ModelScanner._update_metadata_paths` does.
|
||||
After the move, scanner caches are reconciled so the list API immediately
|
||||
serves the new preview locations instead of stale pre-migration URLs.
|
||||
|
||||
Intended flow (settings-first):
|
||||
|
||||
1. The user switches ``sidecar_storage_mode`` (and optionally
|
||||
``sidecar_storage_path``) in settings.
|
||||
2. The migration runs in the direction of the NEW mode with ``force=True``.
|
||||
After the switch, files in the OLD layout are the source of truth; the
|
||||
guard below would otherwise refuse to run because the active mode already
|
||||
matches the migration target.
|
||||
|
||||
Both orderings work because all path computations are mode-independent: the
|
||||
alongside location is derived from the model path directly, and the mirror
|
||||
location is resolved via ``get_configured_sidecar_root()``, which ignores the
|
||||
active mode.
|
||||
|
||||
Guards (pass ``force=True`` to bypass):
|
||||
|
||||
- ``migrate_to_centralized`` refuses when centralized storage is already the
|
||||
active, resolvable mode.
|
||||
- ``migrate_to_alongside`` refuses when the active mode is ``alongside``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
|
||||
|
||||
from ..service_registry import ServiceRegistry
|
||||
from ..settings_manager import get_settings_manager
|
||||
from ...utils.constants import PREVIEW_EXTENSIONS
|
||||
from ...utils.file_utils import find_preview_file, get_preview_extension
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
from ...utils.sidecar_paths import (
|
||||
METADATA_SUFFIX,
|
||||
ROOT_MAP_FILENAME,
|
||||
STORAGE_MODE_CENTRALIZED,
|
||||
get_configured_sidecar_root,
|
||||
get_sidecar_root,
|
||||
get_storage_mode,
|
||||
relocate_root_map,
|
||||
resolve_centralized_dir_for_dir,
|
||||
)
|
||||
|
||||
|
||||
class SidecarMigrationProgressReporter(Protocol):
|
||||
"""Protocol for progress reporters used during sidecar migration."""
|
||||
|
||||
async def on_progress(self, payload: Dict[str, Any]) -> None:
|
||||
"""Handle a sidecar migration progress update."""
|
||||
|
||||
|
||||
ScannerFactory = Callable[[], Awaitable[Any]]
|
||||
|
||||
DIRECTION_TO_CENTRALIZED = "to_centralized"
|
||||
DIRECTION_TO_ALONGSIDE = "to_alongside"
|
||||
DIRECTION_RELOCATE_ROOT = "relocate_root"
|
||||
|
||||
# Same candidate set find_preview_file recognizes: every PREVIEW_EXTENSIONS
|
||||
# suffix plus the legacy ".example.0.jpeg" (issue #225).
|
||||
_PREVIEW_CANDIDATE_EXTENSIONS = tuple(PREVIEW_EXTENSIONS) + (".example.0.jpeg",)
|
||||
|
||||
|
||||
def _enumerate_preview_names(directory: str, stem: str) -> List[str]:
|
||||
"""Return preview filenames for ``stem`` present in ``directory``.
|
||||
|
||||
Case-insensitive full-name match against the preview candidate set, so
|
||||
files like ``model.WEBP`` or ``model.Png`` placed by external tools are
|
||||
migrated along with the exact-case variants.
|
||||
"""
|
||||
|
||||
targets = {f"{stem.lower()}{ext}" for ext in _PREVIEW_CANDIDATE_EXTENSIONS}
|
||||
try:
|
||||
entries = os.listdir(directory)
|
||||
except OSError:
|
||||
return []
|
||||
return [entry for entry in entries if entry.lower() in targets]
|
||||
|
||||
|
||||
class SidecarMigrationUseCase:
|
||||
"""Move sidecars and previews between alongside and centralized layouts."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
scanner_factories: Sequence[Tuple[str, ScannerFactory]] | None = None,
|
||||
settings_service=None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
) -> None:
|
||||
self._settings = settings_service or get_settings_manager()
|
||||
self._scanner_factories: Tuple[Tuple[str, ScannerFactory], ...] = tuple(
|
||||
scanner_factories
|
||||
or (
|
||||
("lora", ServiceRegistry.get_lora_scanner),
|
||||
("checkpoint", ServiceRegistry.get_checkpoint_scanner),
|
||||
("embedding", ServiceRegistry.get_embedding_scanner),
|
||||
("other", ServiceRegistry.get_other_scanner),
|
||||
)
|
||||
)
|
||||
self._logger = logger or logging.getLogger(__name__)
|
||||
|
||||
async def migrate_to_centralized(
|
||||
self,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move sidecars/previews from alongside the models into the mirror root."""
|
||||
|
||||
if (
|
||||
not force
|
||||
and get_storage_mode() == STORAGE_MODE_CENTRALIZED
|
||||
and get_sidecar_root()
|
||||
):
|
||||
return self._refusal(
|
||||
DIRECTION_TO_CENTRALIZED,
|
||||
"sidecar storage is already centralized; pass force=true to migrate anyway",
|
||||
)
|
||||
return await self._migrate(
|
||||
direction=DIRECTION_TO_CENTRALIZED,
|
||||
to_centralized=True,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
async def migrate_to_alongside(
|
||||
self,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move sidecars/previews from the mirror root back next to the models."""
|
||||
|
||||
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return self._refusal(
|
||||
DIRECTION_TO_ALONGSIDE,
|
||||
"sidecar storage is already alongside; pass force=true to migrate anyway",
|
||||
)
|
||||
return await self._migrate(
|
||||
direction=DIRECTION_TO_ALONGSIDE,
|
||||
to_centralized=False,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
async def migrate_root(
|
||||
self,
|
||||
old_root: str,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Relocate the whole mirror tree from a previous root to the configured one.
|
||||
|
||||
Used after ``sidecar_storage_path`` changes while centralized storage
|
||||
is active: without it, every asset under the old root would silently
|
||||
disappear from the application. Moves every file keeping the
|
||||
root-relative structure, rewrites the ``preview_url`` prefix inside
|
||||
moved sidecars, reconciles scanner caches, and prunes the emptied old
|
||||
tree. Keep-newer conflict resolution matches :meth:`_transfer`.
|
||||
"""
|
||||
|
||||
if not force and get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"sidecar storage is not centralized; pass force=true to relocate anyway",
|
||||
)
|
||||
new_root = get_configured_sidecar_root()
|
||||
if not new_root:
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"cannot resolve the centralized sidecar root",
|
||||
)
|
||||
old = (
|
||||
os.path.abspath(os.path.expanduser(old_root.strip()))
|
||||
if isinstance(old_root, str) and old_root.strip()
|
||||
else ""
|
||||
)
|
||||
if not old:
|
||||
return self._refusal(DIRECTION_RELOCATE_ROOT, "old_root is required")
|
||||
if os.path.normpath(old) == os.path.normpath(new_root):
|
||||
return self._refusal(
|
||||
DIRECTION_RELOCATE_ROOT,
|
||||
"old_root matches the configured sidecar root",
|
||||
)
|
||||
|
||||
files: List[Tuple[str, str]] = []
|
||||
source_map_path = os.path.join(old, ROOT_MAP_FILENAME)
|
||||
if os.path.isdir(old):
|
||||
for dirpath, _dirnames, filenames in os.walk(old):
|
||||
rel = os.path.relpath(dirpath, old)
|
||||
target_dir = new_root if rel == os.curdir else os.path.join(new_root, rel)
|
||||
for filename in filenames:
|
||||
source = os.path.join(dirpath, filename)
|
||||
# The identity map is handled by relocate_root_map below:
|
||||
# _transfer's keep-newer rule would let a destination map
|
||||
# written before the relocation displace it.
|
||||
if source == source_map_path:
|
||||
continue
|
||||
files.append((source, os.path.join(target_dir, filename)))
|
||||
|
||||
errors: List[Dict[str, str]] = []
|
||||
counters: Dict[str, Any] = {"moved": 0, "conflicts": 0}
|
||||
moved_sidecars: List[str] = []
|
||||
|
||||
async def emit(status: str, **extra: Any) -> None:
|
||||
if progress_cb is None:
|
||||
return
|
||||
payload: Dict[str, Any] = {
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": status,
|
||||
"direction": DIRECTION_RELOCATE_ROOT,
|
||||
"total": len(files),
|
||||
"processed": extra.pop("processed", 0),
|
||||
"moved": counters["moved"],
|
||||
"skipped": 0,
|
||||
"conflicts": counters["conflicts"],
|
||||
"errors": len(errors),
|
||||
}
|
||||
payload.update(extra)
|
||||
await progress_cb.on_progress(payload)
|
||||
|
||||
await emit("started")
|
||||
|
||||
for index, (src, dst) in enumerate(files, start=1):
|
||||
try:
|
||||
if self._transfer(src, dst, counters) and src.endswith(METADATA_SUFFIX):
|
||||
moved_sidecars.append(dst)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation failed for %s: %s", src, exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": os.path.basename(src), "error": str(exc)})
|
||||
await emit("processing", processed=index, current=os.path.basename(src))
|
||||
|
||||
# The identity map names the directories just moved, so it travels with
|
||||
# them and wins over any map the destination acquired beforehand.
|
||||
# A failure here strands the moved metadata, so it is a real error.
|
||||
try:
|
||||
if not relocate_root_map(old, new_root):
|
||||
errors.append(
|
||||
{
|
||||
"model": ROOT_MAP_FILENAME,
|
||||
"error": "sidecar root map could not be written to the new root",
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation failed for the root map: %s", exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": ROOT_MAP_FILENAME, "error": str(exc)})
|
||||
|
||||
old_prefix = old.replace(os.sep, "/").rstrip("/") + "/"
|
||||
new_prefix = new_root.replace(os.sep, "/").rstrip("/") + "/"
|
||||
for sidecar in moved_sidecars:
|
||||
self._rewrite_root_prefix(sidecar, old_prefix, new_prefix)
|
||||
await self._reconcile_root_prefix(old_prefix, new_prefix)
|
||||
|
||||
# Prune the emptied old tree, best-effort.
|
||||
if os.path.isdir(old):
|
||||
for dirpath, dirnames, filenames in os.walk(old, topdown=False):
|
||||
if filenames:
|
||||
continue
|
||||
for dirname in dirnames:
|
||||
try:
|
||||
os.rmdir(os.path.join(dirpath, dirname))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(dirpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
await emit("completed")
|
||||
|
||||
return {
|
||||
"success": not errors,
|
||||
"direction": DIRECTION_RELOCATE_ROOT,
|
||||
"models_total": len(files),
|
||||
"models_processed": len(files),
|
||||
"models_moved": 0,
|
||||
"moved": counters["moved"],
|
||||
"skipped": 0,
|
||||
"conflicts": counters["conflicts"],
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
"sidecar_root": new_root,
|
||||
}
|
||||
|
||||
def _rewrite_root_prefix(
|
||||
self, sidecar_path: str, old_prefix: str, new_prefix: str
|
||||
) -> None:
|
||||
"""Repoint preview_url inside a relocated sidecar from old to new root."""
|
||||
|
||||
try:
|
||||
with open(sidecar_path, "r", encoding="utf-8") as handle:
|
||||
metadata = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
self._logger.warning(
|
||||
"Sidecar root relocation: cannot read %s: %s", sidecar_path, exc
|
||||
)
|
||||
return
|
||||
|
||||
preview_url = metadata.get("preview_url")
|
||||
if not isinstance(preview_url, str) or not preview_url.startswith(old_prefix):
|
||||
return
|
||||
metadata["preview_url"] = new_prefix + preview_url[len(old_prefix):]
|
||||
try:
|
||||
with open(sidecar_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(metadata, handle, ensure_ascii=False, indent=2)
|
||||
except OSError as exc:
|
||||
self._logger.warning(
|
||||
"Sidecar root relocation: cannot rewrite %s: %s", sidecar_path, exc
|
||||
)
|
||||
|
||||
async def _reconcile_root_prefix(self, old_prefix: str, new_prefix: str) -> None:
|
||||
"""Rewrite old-root preview URLs in every scanner cache after relocation."""
|
||||
|
||||
for model_type, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
cache = await scanner.get_cached_data()
|
||||
changed = False
|
||||
for item in cache.raw_data:
|
||||
preview_url = item.get("preview_url")
|
||||
if (
|
||||
isinstance(preview_url, str)
|
||||
and preview_url.startswith(old_prefix)
|
||||
):
|
||||
item["preview_url"] = new_prefix + preview_url[len(old_prefix):]
|
||||
changed = True
|
||||
if changed and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar root relocation: failed to reconcile %s cache: %s",
|
||||
model_type,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _refusal(direction: str, message: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"success": False,
|
||||
"error": message,
|
||||
"direction": direction,
|
||||
"models_total": 0,
|
||||
"models_processed": 0,
|
||||
"models_moved": 0,
|
||||
"moved": 0,
|
||||
"skipped": 0,
|
||||
"conflicts": 0,
|
||||
"errors": [],
|
||||
"error_count": 0,
|
||||
"sidecar_root": get_configured_sidecar_root() or "",
|
||||
}
|
||||
|
||||
def _active_scanner_factories(self) -> Tuple[Tuple[str, ScannerFactory], ...]:
|
||||
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||
|
||||
if self._settings.is_other_models_enabled():
|
||||
return self._scanner_factories
|
||||
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
|
||||
|
||||
async def _collect_model_paths(
|
||||
self, errors: List[Dict[str, str]]
|
||||
) -> List[Tuple[Any, List[str]]]:
|
||||
"""Enumerate model file paths grouped by the scanner that owns them.
|
||||
|
||||
Excluded models are included: they are absent from the cache but still
|
||||
on disk, and leaving their sidecars behind would strand the metadata
|
||||
if the user later un-excludes them (the scanner would then look the
|
||||
sidecar up in the NEW layout and find nothing).
|
||||
"""
|
||||
|
||||
groups: List[Tuple[Any, List[str]]] = []
|
||||
for model_type, factory in self._active_scanner_factories():
|
||||
try:
|
||||
scanner = await factory()
|
||||
cache = await scanner.get_cached_data()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to enumerate %s models: %s",
|
||||
model_type,
|
||||
exc,
|
||||
)
|
||||
errors.append({"model": model_type, "error": f"enumeration failed: {exc}"})
|
||||
continue
|
||||
paths = [
|
||||
entry["file_path"]
|
||||
for entry in cache.raw_data
|
||||
if entry.get("file_path")
|
||||
]
|
||||
get_excluded = getattr(scanner, "get_excluded_models", None)
|
||||
if callable(get_excluded):
|
||||
try:
|
||||
known = set(paths)
|
||||
paths.extend(
|
||||
path for path in get_excluded() if path and path not in known
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to enumerate excluded %s models: %s",
|
||||
model_type,
|
||||
exc,
|
||||
)
|
||||
groups.append((scanner, paths))
|
||||
return groups
|
||||
|
||||
@staticmethod
|
||||
def _move_file(src: str, dst: str) -> None:
|
||||
"""Move a file, tolerating EXDEV when the layouts span filesystems."""
|
||||
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
try:
|
||||
os.rename(src, dst)
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EXDEV:
|
||||
raise
|
||||
shutil.copy2(src, dst)
|
||||
os.remove(src)
|
||||
|
||||
async def _migrate(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
to_centralized: bool,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter],
|
||||
) -> Dict[str, Any]:
|
||||
root = get_configured_sidecar_root()
|
||||
if not root:
|
||||
return self._refusal(
|
||||
direction,
|
||||
"cannot resolve the centralized sidecar root",
|
||||
)
|
||||
|
||||
errors: List[Dict[str, str]] = []
|
||||
scanner_groups = await self._collect_model_paths(errors)
|
||||
|
||||
total = sum(len(paths) for _, paths in scanner_groups)
|
||||
processed = 0
|
||||
models_moved = 0
|
||||
moved = 0
|
||||
skipped = 0
|
||||
conflicts = 0
|
||||
# (file_path, final preview path at the destination layout), grouped
|
||||
# by scanner so caches can be reconciled after the move.
|
||||
preview_updates: List[Tuple[Any, List[Tuple[str, str]]]] = []
|
||||
|
||||
async def emit(status: str, **extra: Any) -> None:
|
||||
if progress_cb is None:
|
||||
return
|
||||
payload: Dict[str, Any] = {
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": status,
|
||||
"direction": direction,
|
||||
"total": total,
|
||||
"processed": processed,
|
||||
"moved": moved,
|
||||
"skipped": skipped,
|
||||
"conflicts": conflicts,
|
||||
"errors": len(errors),
|
||||
}
|
||||
payload.update(extra)
|
||||
await progress_cb.on_progress(payload)
|
||||
|
||||
await emit("started")
|
||||
|
||||
for scanner, model_paths in scanner_groups:
|
||||
updates: List[Tuple[str, str]] = []
|
||||
for model_path in model_paths:
|
||||
processed += 1
|
||||
current = os.path.basename(model_path)
|
||||
try:
|
||||
result = await self._migrate_model(
|
||||
model_path,
|
||||
root=root,
|
||||
to_centralized=to_centralized,
|
||||
)
|
||||
moved += result["moved"]
|
||||
conflicts += result["conflicts"]
|
||||
if result["skipped"]:
|
||||
skipped += 1
|
||||
else:
|
||||
updates.append((model_path, result["preview_url"]))
|
||||
if result["moved"]:
|
||||
models_moved += 1
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration failed for %s: %s", model_path, exc, exc_info=True
|
||||
)
|
||||
errors.append({"model": current, "error": str(exc)})
|
||||
await emit("processing", current=current)
|
||||
preview_updates.append((scanner, updates))
|
||||
|
||||
await self._reconcile_scanner_caches(preview_updates)
|
||||
|
||||
await emit("completed")
|
||||
|
||||
return {
|
||||
"success": not errors,
|
||||
"direction": direction,
|
||||
"models_total": total,
|
||||
"models_processed": processed,
|
||||
"models_moved": models_moved,
|
||||
"moved": moved,
|
||||
"skipped": skipped,
|
||||
"conflicts": conflicts,
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
# Effective centralized root, so the UI can show/offer to open the
|
||||
# destination (or, for to_alongside, the source) after the run.
|
||||
"sidecar_root": root,
|
||||
}
|
||||
|
||||
async def _migrate_model(
|
||||
self,
|
||||
model_path: str,
|
||||
*,
|
||||
root: str,
|
||||
to_centralized: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Migrate one model's sidecar + previews; return per-model counters.
|
||||
|
||||
``preview_url`` in the result is the model's final preview path in the
|
||||
destination layout ("" when none), used to reconcile scanner caches.
|
||||
"""
|
||||
|
||||
result: Dict[str, Any] = {"moved": 0, "conflicts": 0, "skipped": 0, "preview_url": ""}
|
||||
|
||||
model_path = os.path.abspath(model_path)
|
||||
if not os.path.exists(model_path):
|
||||
self._logger.warning(
|
||||
"Sidecar migration: model file missing, skipping: %s", model_path
|
||||
)
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
model_dir = os.path.dirname(model_path)
|
||||
mirror_dir = resolve_centralized_dir_for_dir(model_dir, sidecar_root=root)
|
||||
if mirror_dir is None:
|
||||
self._logger.warning(
|
||||
"Sidecar migration: %s is outside configured model roots, skipping",
|
||||
model_path,
|
||||
)
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
if to_centralized:
|
||||
src_dir, dst_dir = model_dir, mirror_dir
|
||||
else:
|
||||
src_dir, dst_dir = mirror_dir, model_dir
|
||||
|
||||
if os.path.normpath(src_dir) == os.path.normpath(dst_dir):
|
||||
result["skipped"] = 1
|
||||
return result
|
||||
|
||||
stem = os.path.splitext(os.path.basename(model_path))[0]
|
||||
sidecar_name = stem + METADATA_SUFFIX
|
||||
|
||||
moved_previews: List[str] = []
|
||||
for preview_name in _enumerate_preview_names(src_dir, stem):
|
||||
src = os.path.join(src_dir, preview_name)
|
||||
dst = os.path.join(dst_dir, preview_name)
|
||||
if self._transfer(src, dst, result):
|
||||
moved_previews.append(dst)
|
||||
|
||||
sidecar_src = os.path.join(src_dir, sidecar_name)
|
||||
sidecar_moved = False
|
||||
sidecar_dst = os.path.join(dst_dir, sidecar_name)
|
||||
if os.path.exists(sidecar_src):
|
||||
sidecar_moved = self._transfer(sidecar_src, sidecar_dst, result)
|
||||
|
||||
if sidecar_moved:
|
||||
await self._rewrite_sidecar_paths(sidecar_dst, model_path, moved_previews)
|
||||
|
||||
# Ground truth from the destination directory: covers conflict-keep
|
||||
# and partial moves, not just the previews transferred in this run.
|
||||
final_preview = find_preview_file(stem, dst_dir)
|
||||
if final_preview:
|
||||
result["preview_url"] = final_preview.replace(os.sep, "/")
|
||||
|
||||
return result
|
||||
|
||||
async def _reconcile_scanner_caches(
|
||||
self, preview_updates: List[Tuple[Any, List[Tuple[str, str]]]]
|
||||
) -> None:
|
||||
"""Point scanner cache entries at the post-migration preview locations.
|
||||
|
||||
Without this the list API keeps serving pre-migration ``preview_url``
|
||||
values whose files no longer exist; hitting one triggers the preview
|
||||
route's stale-URL cleanup, which would wipe the reference for good.
|
||||
A failing scanner is logged and skipped — the on-disk migration has
|
||||
already succeeded, and a full rescan repairs the cache.
|
||||
"""
|
||||
|
||||
for scanner, updates in preview_updates:
|
||||
if not updates:
|
||||
continue
|
||||
try:
|
||||
cache = await scanner.get_cached_data()
|
||||
changed = False
|
||||
for file_path, preview_url in updates:
|
||||
entry = next(
|
||||
(item for item in cache.raw_data if item.get("file_path") == file_path),
|
||||
None,
|
||||
)
|
||||
if entry is None:
|
||||
continue
|
||||
if entry.get("preview_url", "") == preview_url:
|
||||
continue
|
||||
if hasattr(cache, "update_preview_url"):
|
||||
await cache.update_preview_url(
|
||||
file_path,
|
||||
preview_url,
|
||||
entry.get("preview_nsfw_level", 0),
|
||||
)
|
||||
else: # pragma: no cover - minimal cache doubles
|
||||
entry["preview_url"] = preview_url
|
||||
changed = True
|
||||
if changed and hasattr(scanner, "_persist_current_cache"):
|
||||
await scanner._persist_current_cache()
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to reconcile scanner cache: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _raise_path_error(exc: OSError, dst: str) -> None:
|
||||
"""Re-raise path-length OS errors with an actionable message.
|
||||
|
||||
Windows rejects paths over MAX_PATH (260 chars) with WinError 206
|
||||
(ERROR_FILENAME_EXCED_RANGE); Linux reports ENAMETOOLONG. A bare
|
||||
OSError gives no hint that the sidecar root is simply too deep.
|
||||
"""
|
||||
|
||||
if getattr(exc, "winerror", None) == 206 or exc.errno == errno.ENAMETOOLONG:
|
||||
raise OSError(
|
||||
f"Destination path is too long for the OS ({len(dst)} chars; "
|
||||
f"Windows limit is 260): {dst}. Choose a shallower sidecar "
|
||||
f"storage directory or shorten the file name."
|
||||
) from exc
|
||||
raise exc
|
||||
|
||||
def _transfer(self, src: str, dst: str, result: Dict[str, Any]) -> bool:
|
||||
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
|
||||
|
||||
Returns True when the file was actually moved to the destination. On a
|
||||
conflict the newer file wins: a newer source replaces the destination;
|
||||
a newer (or equal) destination is kept and the source is deleted.
|
||||
"""
|
||||
|
||||
if os.path.exists(dst):
|
||||
result["conflicts"] += 1
|
||||
if os.path.getmtime(src) > os.path.getmtime(dst):
|
||||
self._logger.info(
|
||||
"Sidecar migration: conflict at %s; source is newer, replacing", dst
|
||||
)
|
||||
os.remove(dst)
|
||||
else:
|
||||
self._logger.info(
|
||||
"Sidecar migration: conflict at %s; destination is newer, keeping it",
|
||||
dst,
|
||||
)
|
||||
os.remove(src)
|
||||
return False
|
||||
try:
|
||||
self._move_file(src, dst)
|
||||
except OSError as exc:
|
||||
self._raise_path_error(exc, dst)
|
||||
result["moved"] += 1
|
||||
return True
|
||||
|
||||
async def _rewrite_sidecar_paths(
|
||||
self,
|
||||
sidecar_path: str,
|
||||
model_path: str,
|
||||
moved_previews: List[str],
|
||||
) -> None:
|
||||
"""Update path fields inside a moved sidecar, mirroring ModelScanner."""
|
||||
|
||||
with open(sidecar_path, "r", encoding="utf-8") as handle:
|
||||
metadata = json.load(handle)
|
||||
|
||||
stem = os.path.splitext(os.path.basename(model_path))[0]
|
||||
metadata["file_path"] = model_path.replace(os.sep, "/")
|
||||
metadata["file_name"] = stem
|
||||
|
||||
if moved_previews and metadata.get("preview_url"):
|
||||
recorded_ext = get_preview_extension(metadata["preview_url"])
|
||||
chosen = next(
|
||||
(
|
||||
path
|
||||
for path in moved_previews
|
||||
if get_preview_extension(path) == recorded_ext
|
||||
),
|
||||
moved_previews[0],
|
||||
)
|
||||
metadata["preview_url"] = chosen.replace(os.sep, "/")
|
||||
|
||||
await MetadataManager.save_metadata(sidecar_path, metadata)
|
||||
|
||||
async def execute_with_error_handling(
|
||||
self,
|
||||
*,
|
||||
direction: str,
|
||||
progress_cb: Optional[SidecarMigrationProgressReporter] = None,
|
||||
force: bool = False,
|
||||
old_root: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrapper providing progress notification on unexpected failures."""
|
||||
|
||||
try:
|
||||
if direction == DIRECTION_TO_CENTRALIZED:
|
||||
return await self.migrate_to_centralized(progress_cb, force=force)
|
||||
if direction == DIRECTION_TO_ALONGSIDE:
|
||||
return await self.migrate_to_alongside(progress_cb, force=force)
|
||||
if direction == DIRECTION_RELOCATE_ROOT:
|
||||
return await self.migrate_root(old_root or "", progress_cb, force=force)
|
||||
raise ValueError(
|
||||
f"direction must be {DIRECTION_TO_CENTRALIZED!r}, "
|
||||
f"{DIRECTION_TO_ALONGSIDE!r} or {DIRECTION_RELOCATE_ROOT!r}"
|
||||
)
|
||||
except Exception as exc:
|
||||
if progress_cb is not None:
|
||||
await progress_cb.on_progress(
|
||||
{
|
||||
"type": "sidecar_migration_progress",
|
||||
"status": "error",
|
||||
"direction": direction,
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
raise
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ PREVIEW_EXTENSIONS = [
|
||||
# Card preview image width
|
||||
CARD_PREVIEW_WIDTH = 480
|
||||
|
||||
# Upper bound for a ComfyUI workflow embedded into a recipe preview on the
|
||||
# opt-in widget save path. The workflow is by far the largest metadata field
|
||||
# (tens of KB for a simple graph), so an anomalous graph — e.g. one carrying
|
||||
# base64 blobs in widget values — is skipped instead of inflating the preview.
|
||||
# Imports are deliberately not capped: their workflow comes from an image the
|
||||
# user already chose, and preserving it is the point.
|
||||
MAX_WORKFLOW_EMBED_BYTES = 256 * 1024
|
||||
|
||||
# Width for optimized example images
|
||||
EXAMPLE_IMAGE_WIDTH = 832
|
||||
|
||||
@@ -344,12 +352,15 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
"PixArt E",
|
||||
# Video diffusion models
|
||||
"CogVideoX",
|
||||
"Flux 3 Video",
|
||||
"Hunyuan Video",
|
||||
"LTXV",
|
||||
"LTXV2",
|
||||
"LTXV 2.3",
|
||||
"LTXV 2.5",
|
||||
"Mochi",
|
||||
"SVD",
|
||||
"SVD XT",
|
||||
"Wan Video",
|
||||
"Wan Video 1.3B t2v",
|
||||
"Wan Video 14B t2v",
|
||||
@@ -360,11 +371,19 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
"Wan Video 2.2 T2V-A14B",
|
||||
"Wan Video 2.5 T2V",
|
||||
"Wan Video 2.5 I2V",
|
||||
"Wan Video 2.7",
|
||||
"Wan Video 3.0",
|
||||
# Other diffusion models
|
||||
"Boogu",
|
||||
"Ernie",
|
||||
"Ernie Turbo",
|
||||
"HiDream-O1",
|
||||
"Ming Image Design 0.1",
|
||||
"Ming Image Design Layer 0.1",
|
||||
"MiniMax H3",
|
||||
"Nucleus",
|
||||
"Qwen",
|
||||
"Wan Image 2.7",
|
||||
"ZImageBase",
|
||||
"ZImageTurbo",
|
||||
# Krea 2 — loaded via UNETLoader in ComfyUI
|
||||
@@ -372,6 +391,53 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
]
|
||||
)
|
||||
|
||||
# baseModel values from CivitAI that are true full checkpoints (loaded via
|
||||
# CheckpointLoaderSimple in ComfyUI). New DiT families appear on CivitAI all
|
||||
# the time, so download routing inverts the fallback: anything NOT in this
|
||||
# closed set (and not a known diffusion model) is treated as a diffusion
|
||||
# model by default (see py/services/download_routing.py). Cross-checked
|
||||
# against CivitAI's official baseModelRecords (packages/civitai-shared
|
||||
# src/basemodel.constants.ts), not only the download skip list.
|
||||
# "Pony V7" is deliberately excluded: verified via the live API (model
|
||||
# 1901521) to be AuraFlow-architecture shipping .gguf variants (UNETLoader),
|
||||
# so it follows the unknown-base-model default (diffusion).
|
||||
CHECKPOINT_BASE_MODELS = frozenset(
|
||||
[
|
||||
# Stable Diffusion 1.x
|
||||
"SD 1.4",
|
||||
"SD 1.5",
|
||||
"SD 1.5 LCM",
|
||||
"SD 1.5 Hyper",
|
||||
# Stable Diffusion 2.x
|
||||
"SD 2.0",
|
||||
"SD 2.0 768",
|
||||
"SD 2.1",
|
||||
"SD 2.1 768",
|
||||
"SD 2.1 Unclip",
|
||||
# Stable Diffusion 3.x
|
||||
"SD 3",
|
||||
"SD 3.5",
|
||||
"SD 3.5 Medium",
|
||||
"SD 3.5 Large",
|
||||
"SD 3.5 Large Turbo",
|
||||
# SDXL and its full-checkpoint derivatives
|
||||
"SDXL 0.9",
|
||||
"SDXL 1.0",
|
||||
"SDXL 1.0 LCM",
|
||||
"SDXL Lightning",
|
||||
"SDXL Hyper",
|
||||
"SDXL Turbo",
|
||||
"SDXL Distilled",
|
||||
"Pony",
|
||||
"Illustrious",
|
||||
"NoobAI",
|
||||
# Other full-checkpoint families
|
||||
"Playground v2",
|
||||
# Stable Cascade is unCLIP-style but loads via CheckpointLoader
|
||||
"Stable Cascade",
|
||||
]
|
||||
)
|
||||
|
||||
# Supported baseModel values for download exclusion settings.
|
||||
# Keep this aligned with static/js/utils/constants.js, excluding the generic "Other" value.
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS = frozenset(
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..utils.example_images_paths import (
|
||||
ExampleImagePathResolver,
|
||||
ensure_library_root_exists,
|
||||
get_example_images_root,
|
||||
get_library_root,
|
||||
is_hash_folder,
|
||||
uses_library_scoped_folders,
|
||||
)
|
||||
@@ -948,11 +949,26 @@ class DownloadManager:
|
||||
|
||||
return True # Return True to indicate a remote download happened
|
||||
else:
|
||||
# No civitai data or images available, mark as failed to avoid future attempts
|
||||
self._progress["failed_models"].add(model_hash)
|
||||
logger.debug(
|
||||
f"No civitai images available for model {model_name}, marking as failed"
|
||||
# No example images available. "Metadata not fetched yet"
|
||||
# (no civitai payload at all) is retryable — a later run only
|
||||
# re-reads local metadata, so no extra provider API calls.
|
||||
# Confirmed absences (provider metadata present but
|
||||
# image-less, or the model is known to be gone from CivitAI)
|
||||
# stay on the failed list to avoid future attempts.
|
||||
metadata_missing = not civitai_payload
|
||||
known_absent = bool((full_model or {}).get("civitai_deleted")) or (
|
||||
(full_model or {}).get("from_civitai") is False
|
||||
)
|
||||
if metadata_missing and not known_absent:
|
||||
logger.debug(
|
||||
"No civitai metadata yet for model %s, leaving it pending for a later run",
|
||||
model_name,
|
||||
)
|
||||
else:
|
||||
self._progress["failed_models"].add(model_hash)
|
||||
logger.debug(
|
||||
f"No civitai images available for model {model_name}, marking as failed"
|
||||
)
|
||||
|
||||
# Save progress periodically
|
||||
if (
|
||||
@@ -968,8 +984,8 @@ class DownloadManager:
|
||||
logger.error(error_msg, exc_info=True)
|
||||
self._progress["errors"].append(error_msg)
|
||||
self._progress["last_error"] = error_msg
|
||||
# Ensure model is marked as failed so we don't try again in this run
|
||||
self._progress["failed_models"].add(model_hash)
|
||||
# Transient failures (network, disk, ...) must not blacklist the
|
||||
# model: leave it pending so the next run retries it.
|
||||
return False
|
||||
|
||||
def _save_progress(self, output_dir):
|
||||
@@ -1546,6 +1562,98 @@ class DownloadManager:
|
||||
_default_download_manager: DownloadManager | None = None
|
||||
|
||||
|
||||
def _progress_file_candidates(library_name: str | None = None) -> List[str]:
|
||||
"""Return existing-progress-file locations for a library (plus the legacy root)."""
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
if not settings_manager.get("example_images_path"):
|
||||
return []
|
||||
|
||||
candidates: List[str] = []
|
||||
library_root = get_library_root(
|
||||
library_name or settings_manager.get_active_library_name()
|
||||
)
|
||||
if library_root:
|
||||
candidates.append(os.path.join(library_root, ".download_progress.json"))
|
||||
|
||||
if uses_library_scoped_folders():
|
||||
legacy_root = get_example_images_root()
|
||||
if legacy_root:
|
||||
legacy_file = os.path.join(legacy_root, ".download_progress.json")
|
||||
if legacy_file not in candidates:
|
||||
candidates.append(legacy_file)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def _remove_hashes_from_progress_file(progress_file: str, hashes: Set[str]) -> int:
|
||||
"""Remove *hashes* from the failed_models list of one progress file."""
|
||||
|
||||
if not os.path.exists(progress_file):
|
||||
return 0
|
||||
|
||||
try:
|
||||
with open(progress_file, "r", encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
# Cheap pre-filter: skip the JSON parse and rewrite when none of the
|
||||
# hashes are even mentioned.
|
||||
if not any(h in raw for h in hashes):
|
||||
return 0
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
failed = data.get("failed_models")
|
||||
if not isinstance(failed, list) or not failed:
|
||||
return 0
|
||||
|
||||
remaining = [h for h in failed if (h or "").lower() not in hashes]
|
||||
removed = len(failed) - len(remaining)
|
||||
if not removed:
|
||||
return 0
|
||||
|
||||
data["failed_models"] = remaining
|
||||
try:
|
||||
with open(progress_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def unblock_failed_example_image_models(
|
||||
model_hashes: Iterable[str], library_name: str | None = None
|
||||
) -> int:
|
||||
"""Remove hashes from the failed-models list so the next run retries them.
|
||||
|
||||
Called when fresh provider metadata lands for a model (metadata fetch or
|
||||
relink), which invalidates any earlier "no example images" verdict. Only
|
||||
rewrites the local progress file — no provider API calls. Also updates the
|
||||
live download manager's in-memory state when one exists.
|
||||
"""
|
||||
|
||||
normalized = {h.lower() for h in model_hashes if h}
|
||||
if not normalized:
|
||||
return 0
|
||||
|
||||
manager = _default_download_manager
|
||||
if manager is not None:
|
||||
failed = manager._progress.get("failed_models")
|
||||
if isinstance(failed, set):
|
||||
failed.difference_update(normalized)
|
||||
|
||||
removed = 0
|
||||
for progress_file in _progress_file_candidates(library_name):
|
||||
removed += _remove_hashes_from_progress_file(progress_file, normalized)
|
||||
return removed
|
||||
|
||||
|
||||
def get_default_download_manager(ws_manager) -> DownloadManager:
|
||||
"""Return the singleton download manager used by default routes."""
|
||||
|
||||
|
||||
@@ -26,6 +26,42 @@ class ExampleImagesValidationError(ExampleImagesImportError):
|
||||
class ExampleImagesProcessor:
|
||||
"""Processes and manipulates example images"""
|
||||
|
||||
@staticmethod
|
||||
async def _model_scanners() -> list:
|
||||
"""Return every scanner whose models can carry example images."""
|
||||
return [
|
||||
await ServiceRegistry.get_lora_scanner(),
|
||||
await ServiceRegistry.get_checkpoint_scanner(),
|
||||
await ServiceRegistry.get_embedding_scanner(),
|
||||
await ServiceRegistry.get_other_scanner(),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def resolve_hash_for_file_path(file_path: str) -> str:
|
||||
"""Return the SHA256 for a cached model file, computing it on demand.
|
||||
|
||||
Checkpoint and Other scanners record ``hash_status="pending"`` with an
|
||||
empty sha256 until something needs the hash; importing example images
|
||||
is such a moment because example folders are keyed by hash. Returns
|
||||
``''`` when the file is unknown to every scanner or hashing failed.
|
||||
"""
|
||||
if not file_path:
|
||||
return ''
|
||||
normalized = file_path.replace(os.sep, '/')
|
||||
for scanner in await ExampleImagesProcessor._model_scanners():
|
||||
cache = await scanner.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_path') != normalized:
|
||||
continue
|
||||
sha256 = (item.get('sha256') or '').strip()
|
||||
if sha256 and item.get('hash_status', 'completed') == 'completed':
|
||||
return sha256
|
||||
calculate = getattr(scanner, 'calculate_hash_for_model', None)
|
||||
if calculate is None:
|
||||
return sha256
|
||||
return (await calculate(normalized)) or ''
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def generate_short_id(length=8):
|
||||
"""Generate a short random alphanumeric identifier"""
|
||||
@@ -450,15 +486,11 @@ class ExampleImagesProcessor:
|
||||
raise ExampleImagesValidationError('No example images path configured')
|
||||
|
||||
# Find the model and get current metadata
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
model_data = None
|
||||
scanner = None
|
||||
|
||||
# Check both scanners to find the model
|
||||
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||
# Check every scanner to find the model
|
||||
for scan_obj in await ExampleImagesProcessor._model_scanners():
|
||||
cache = await scan_obj.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
if item.get('sha256') == model_hash:
|
||||
@@ -536,6 +568,7 @@ class ExampleImagesProcessor:
|
||||
'errors': errors,
|
||||
'regular_images': regular_images,
|
||||
'custom_images': custom_images,
|
||||
'model_hash': model_hash,
|
||||
"model_file_path": model_data.get('file_path', ''),
|
||||
}
|
||||
|
||||
@@ -577,15 +610,11 @@ class ExampleImagesProcessor:
|
||||
}, status=400)
|
||||
|
||||
# Find the model and get current metadata
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
model_data = None
|
||||
scanner = None
|
||||
|
||||
# Check both scanners to find the model
|
||||
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||
|
||||
# Check every scanner to find the model
|
||||
for scan_obj in await ExampleImagesProcessor._model_scanners():
|
||||
if scan_obj.has_hash(model_hash):
|
||||
cache = await scan_obj.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
@@ -595,13 +624,13 @@ class ExampleImagesProcessor:
|
||||
break
|
||||
if model_data:
|
||||
break
|
||||
|
||||
|
||||
if not model_data:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Model with hash {model_hash} not found in cache"
|
||||
}, status=404)
|
||||
|
||||
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
civitai_data = model_data.setdefault('civitai', {})
|
||||
custom_images = civitai_data.get('customImages')
|
||||
@@ -737,14 +766,10 @@ class ExampleImagesProcessor:
|
||||
)
|
||||
|
||||
try:
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
|
||||
model_data = None
|
||||
scanner = None
|
||||
|
||||
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
|
||||
for scan_obj in await ExampleImagesProcessor._model_scanners():
|
||||
if scan_obj.has_hash(model_hash):
|
||||
cache = await scan_obj.get_cached_data()
|
||||
for item in cache.raw_data:
|
||||
|
||||
+134
-23
@@ -341,29 +341,125 @@ class ExifUtils:
|
||||
|
||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||
metadata_fields["parameters"] = metadata
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
img_format = img.format
|
||||
|
||||
if img_format == "PNG":
|
||||
png_info = ExifUtils._build_pnginfo(img, metadata_fields)
|
||||
img.save(image_path, format="PNG", pnginfo=png_info)
|
||||
return image_path
|
||||
|
||||
exif_bytes = ExifUtils._build_exif_bytes(
|
||||
metadata_fields, img.info.get("exif")
|
||||
)
|
||||
save_kwargs: dict[str, Any] = {"exif": exif_bytes}
|
||||
if img_format == "WEBP":
|
||||
save_kwargs["quality"] = 85
|
||||
|
||||
img.save(image_path, format=img_format, **save_kwargs)
|
||||
|
||||
return image_path
|
||||
return ExifUtils._write_structured_metadata(image_path, metadata_fields)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating metadata in {image_path}: {e}")
|
||||
return image_path
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _write_structured_metadata(
|
||||
image_path: str, metadata_fields: dict[str, Optional[str]]
|
||||
) -> str:
|
||||
"""Write structured metadata fields back into an image.
|
||||
|
||||
PNG keeps them as text chunks (``parameters``/``prompt``/``workflow``);
|
||||
every other supported container stores them in EXIF, where the workflow
|
||||
travels in ``ImageDescription`` behind a ``Workflow:`` prefix (see
|
||||
:meth:`_build_exif_bytes`).
|
||||
"""
|
||||
with Image.open(image_path) as img:
|
||||
img_format = img.format
|
||||
|
||||
if img_format == "PNG":
|
||||
png_info = ExifUtils._build_pnginfo(img, metadata_fields)
|
||||
img.save(image_path, format="PNG", pnginfo=png_info)
|
||||
return image_path
|
||||
|
||||
exif_bytes = ExifUtils._build_exif_bytes(
|
||||
metadata_fields, img.info.get("exif")
|
||||
)
|
||||
save_kwargs: dict[str, Any] = {"exif": exif_bytes}
|
||||
if img_format == "WEBP":
|
||||
save_kwargs["quality"] = 85
|
||||
|
||||
img.save(image_path, format=img_format, **save_kwargs)
|
||||
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def normalise_workflow(workflow: Any) -> Optional[str]:
|
||||
"""Coerce a workflow payload into the JSON string metadata form.
|
||||
|
||||
Accepts the string form stored in image chunks as well as already
|
||||
decoded dict/list payloads; anything else yields ``None``.
|
||||
"""
|
||||
if isinstance(workflow, str):
|
||||
return workflow or None
|
||||
if isinstance(workflow, (dict, list)):
|
||||
try:
|
||||
return json.dumps(workflow)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _merge_workflow(
|
||||
metadata_fields: Optional[dict[str, Optional[str]]], workflow: Any
|
||||
) -> Optional[dict[str, Optional[str]]]:
|
||||
"""Add a caller-supplied workflow to extracted metadata fields.
|
||||
|
||||
Returns ``metadata_fields`` untouched when there is nothing to add, and
|
||||
never overwrites a workflow the source image already carries.
|
||||
"""
|
||||
workflow_json = ExifUtils.normalise_workflow(workflow)
|
||||
if not workflow_json:
|
||||
return metadata_fields
|
||||
if metadata_fields is None:
|
||||
metadata_fields = {
|
||||
"parameters": None,
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"comment": None,
|
||||
}
|
||||
if not metadata_fields.get("workflow"):
|
||||
metadata_fields["workflow"] = workflow_json
|
||||
return metadata_fields
|
||||
|
||||
@staticmethod
|
||||
def embed_workflow(image_path: str, workflow: Any) -> str:
|
||||
"""Embed a ComfyUI workflow into an image that does not carry one.
|
||||
|
||||
Recipe imports recover the workflow from the source's original
|
||||
rendition (CivitAI's optimized preview is re-encoded and metadata-free)
|
||||
and hand it over as data rather than as image bytes. Images that
|
||||
already embed a workflow are left untouched.
|
||||
|
||||
WebP files are patched at the byte level so preview pixels are not
|
||||
re-encoded a second time.
|
||||
"""
|
||||
workflow_json = ExifUtils.normalise_workflow(workflow)
|
||||
if not image_path or not workflow_json:
|
||||
return image_path
|
||||
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
if ext in ['.mp4', '.webm', '.avif', '.jxl']:
|
||||
return image_path
|
||||
|
||||
try:
|
||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||
if metadata_fields.get("workflow"):
|
||||
return image_path
|
||||
metadata_fields["workflow"] = workflow_json
|
||||
|
||||
if ext == '.webp':
|
||||
try:
|
||||
exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
|
||||
with open(image_path, "rb") as file_obj:
|
||||
image_bytes = file_obj.read()
|
||||
updated = ExifUtils._replace_webp_exif(image_bytes, exif_bytes)
|
||||
with open(image_path, "wb") as file_obj:
|
||||
file_obj.write(updated)
|
||||
return image_path
|
||||
except ValueError:
|
||||
# Container without an EXIF chunk: fall through to a full
|
||||
# rewrite so the workflow is still embedded.
|
||||
pass
|
||||
|
||||
return ExifUtils._write_structured_metadata(image_path, metadata_fields)
|
||||
except Exception as e:
|
||||
logger.error(f"Error embedding workflow in {image_path}: {e}")
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def append_recipe_metadata(image_path, recipe_data, pixel_preserving=False) -> str:
|
||||
"""Append recipe metadata to an image's EXIF data
|
||||
@@ -550,7 +646,7 @@ class ExifUtils:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
|
||||
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False, workflow=None):
|
||||
"""
|
||||
Optimize an image by resizing and converting to WebP format
|
||||
|
||||
@@ -560,10 +656,19 @@ class ExifUtils:
|
||||
format: Output format (default: webp)
|
||||
quality: Output quality (0-100)
|
||||
preserve_metadata: Whether to preserve EXIF metadata
|
||||
workflow: Optional ComfyUI workflow (JSON string, dict or list) to
|
||||
embed when the source image does not carry one. Used by import
|
||||
paths that recover the workflow from a higher-fidelity source
|
||||
(e.g. CivitAI's original rendition) while the preview pixels
|
||||
come from a metadata-free optimized rendition.
|
||||
|
||||
Returns:
|
||||
Tuple of (optimized_image_data, extension)
|
||||
"""
|
||||
# A supplied workflow can only survive when metadata is embedded, so
|
||||
# treat it as an implicit request for preservation.
|
||||
if workflow is not None:
|
||||
preserve_metadata = True
|
||||
try:
|
||||
if isinstance(image_data, str) and os.path.exists(image_data):
|
||||
ext = os.path.splitext(image_data)[1].lower()
|
||||
@@ -627,6 +732,12 @@ class ExifUtils:
|
||||
logger.warning(f"Failed to extract metadata, continuing without it: {e}")
|
||||
# Continue without metadata
|
||||
|
||||
# Merge in a workflow recovered elsewhere (e.g. from CivitAI's
|
||||
# original rendition). The source image wins when it already has
|
||||
# one, and this is what lets the metadata-free optimized preview
|
||||
# still end up with the workflow embedded.
|
||||
metadata_fields = ExifUtils._merge_workflow(metadata_fields, workflow)
|
||||
|
||||
# Calculate new height to maintain aspect ratio
|
||||
width, height = img.size
|
||||
new_height = int(height * (target_width / width))
|
||||
@@ -686,8 +797,8 @@ class ExifUtils:
|
||||
temp_file.write(optimized_data)
|
||||
|
||||
try:
|
||||
ExifUtils.update_image_metadata(
|
||||
temp_path, metadata_fields.get("parameters") or ""
|
||||
ExifUtils._write_structured_metadata(
|
||||
temp_path, metadata_fields
|
||||
)
|
||||
# Read back the file
|
||||
with open(temp_path, 'rb') as f:
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Type, Union, cast
|
||||
from .models import BaseModelMetadata, CheckpointMetadata, EmbeddingMetadata, LoraMetadata
|
||||
from .file_utils import normalize_path, find_preview_file, calculate_sha256, calculate_autov3
|
||||
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
|
||||
from .sidecar_paths import get_metadata_path, get_preview_dir, resolve_metadata_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,7 +33,7 @@ class MetadataManager:
|
||||
- metadata: BaseModelMetadata instance or None
|
||||
- should_skip: True if corrupted metadata file exists and model should be skipped
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
|
||||
# Check if metadata file exists
|
||||
if not os.path.exists(metadata_path):
|
||||
@@ -98,11 +99,7 @@ class MetadataManager:
|
||||
payload.update(unknown_fields)
|
||||
else:
|
||||
if not should_skip:
|
||||
metadata_path = (
|
||||
file_path
|
||||
if file_path.endswith(".metadata.json")
|
||||
else f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
)
|
||||
metadata_path = resolve_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
@@ -150,7 +147,7 @@ class MetadataManager:
|
||||
return model_data
|
||||
|
||||
folder = model_data.get("folder")
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
sidecar_exists = os.path.exists(metadata_path)
|
||||
cached = model_data.copy()
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
@@ -188,15 +185,14 @@ class MetadataManager:
|
||||
bool: Success or failure
|
||||
"""
|
||||
# Determine if the input is a metadata path or a model file path
|
||||
if path.endswith('.metadata.json'):
|
||||
metadata_path = path
|
||||
else:
|
||||
# Use existing logic for model file paths
|
||||
file_path = path
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = resolve_metadata_path(path)
|
||||
temp_path = f"{metadata_path}.tmp"
|
||||
|
||||
try:
|
||||
# Centralized sidecar mirrors may not exist yet (unlike the model's
|
||||
# own directory in alongside mode, which always does).
|
||||
os.makedirs(os.path.dirname(metadata_path), exist_ok=True)
|
||||
|
||||
# Convert to dict if needed
|
||||
if isinstance(metadata, BaseModelMetadata):
|
||||
metadata_dict = metadata.to_dict()
|
||||
@@ -259,10 +255,9 @@ class MetadataManager:
|
||||
|
||||
try:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
|
||||
|
||||
# Calculate file hash
|
||||
start_hash_time = time.perf_counter()
|
||||
@@ -386,15 +381,16 @@ class MetadataManager:
|
||||
# Check if preview exists at the current location
|
||||
preview_url = metadata.preview_url
|
||||
if preview_url:
|
||||
# Get directory parts of both paths
|
||||
file_dir = os.path.dirname(file_path)
|
||||
# Get directory parts of both paths; the preview directory is the
|
||||
# sidecar/preview dir (the model's own dir in alongside mode, the
|
||||
# centralized mirror otherwise).
|
||||
file_dir = get_preview_dir(file_path)
|
||||
preview_dir = os.path.dirname(preview_url)
|
||||
|
||||
# Update preview if it doesn't exist OR if model and preview are in different directories
|
||||
if not os.path.exists(preview_url) or file_dir != preview_dir:
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
new_preview_url = find_preview_file(base_name, dir_path)
|
||||
new_preview_url = find_preview_file(base_name, file_dir)
|
||||
if new_preview_url:
|
||||
metadata.preview_url = normalize_path(new_preview_url)
|
||||
need_update = True
|
||||
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.2.4"
|
||||
version = "1.2.5"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
+25
-1
@@ -64,7 +64,16 @@ body {
|
||||
--border-color: var(--border-base);
|
||||
|
||||
--lora-accent: var(--color-accent);
|
||||
/* Channel aliases: referenced across components (oklch tints, focus
|
||||
rings); previously undeclared, so those declarations silently dropped. */
|
||||
--lora-accent-l: var(--color-accent-l);
|
||||
--lora-accent-c: var(--color-accent-c);
|
||||
--lora-accent-h: var(--color-accent-h);
|
||||
--lora-accent-hover: var(--color-accent-hover);
|
||||
--lora-accent-light: var(--color-accent-subtle);
|
||||
--text-on-accent: var(--color-accent-contrast);
|
||||
--lora-surface: var(--bg-elevated);
|
||||
--lora-surface-solid: var(--bg-elevated-solid);
|
||||
--lora-border: var(--border-subtle);
|
||||
--lora-text: var(--text-primary);
|
||||
--lora-error: var(--color-error);
|
||||
@@ -79,9 +88,12 @@ body {
|
||||
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||
--badge-workflow-bg: var(--color-workflow-bg);
|
||||
--badge-workflow-text: var(--color-workflow-text);
|
||||
--badge-workflow-glow: var(--color-workflow-glow);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
:root[data-theme="dark"] {
|
||||
--bg-color: var(--bg-base);
|
||||
--text-color: var(--text-primary);
|
||||
--text-muted: var(--text-secondary);
|
||||
@@ -89,7 +101,16 @@ body {
|
||||
--border-color: var(--border-base);
|
||||
|
||||
--lora-accent: var(--color-accent);
|
||||
/* Channel aliases: referenced across components (oklch tints, focus
|
||||
rings); previously undeclared, so those declarations silently dropped. */
|
||||
--lora-accent-l: var(--color-accent-l);
|
||||
--lora-accent-c: var(--color-accent-c);
|
||||
--lora-accent-h: var(--color-accent-h);
|
||||
--lora-accent-hover: var(--color-accent-hover);
|
||||
--lora-accent-light: var(--color-accent-subtle);
|
||||
--text-on-accent: var(--color-accent-contrast);
|
||||
--lora-surface: var(--bg-elevated);
|
||||
--lora-surface-solid: var(--bg-elevated-solid);
|
||||
--lora-border: var(--border-subtle);
|
||||
--lora-text: var(--text-primary);
|
||||
--lora-error: var(--color-error);
|
||||
@@ -104,6 +125,9 @@ body {
|
||||
--badge-skip-refresh-bg: var(--color-skip-refresh-bg);
|
||||
--badge-skip-refresh-text: var(--color-skip-refresh-text);
|
||||
--badge-skip-refresh-glow: var(--color-skip-refresh-glow);
|
||||
--badge-workflow-bg: var(--color-workflow-bg);
|
||||
--badge-workflow-text: var(--color-workflow-text);
|
||||
--badge-workflow-glow: var(--color-workflow-glow);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
|
||||
@@ -105,14 +105,14 @@
|
||||
|
||||
.letter-chip:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.letter-chip.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
/* Primary Action Button */
|
||||
.banner-action-primary {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
.banner-action-secondary:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--lora-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
.banner-action-tertiary:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
content: '\f00c';
|
||||
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||
font-weight: 900;
|
||||
color: var(--lora-text);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
white-space: nowrap;
|
||||
padding: 8px 16px;
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
@@ -134,7 +134,7 @@
|
||||
/* Dark theme adjustments for input-with-button */
|
||||
[data-theme="dark"] .input-with-button button {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .input-with-button button:hover {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
|
||||
.show-content-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px var(--space-1);
|
||||
@@ -664,7 +664,8 @@ body.hide-card-version .hl-badge {
|
||||
.card-footer,
|
||||
.card-header,
|
||||
.model-name,
|
||||
.base-model-label {
|
||||
.base-model-label,
|
||||
.workflow-badge {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
@@ -804,7 +805,7 @@ body.hide-card-version .hl-badge {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 3px 6px;
|
||||
font-size: 0.75em;
|
||||
@@ -887,3 +888,26 @@ body.hide-card-version .hl-badge {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Recipe card capability badge: mirrors the model-card update/skip-refresh
|
||||
badges (small square icon next to the base-model pill) but stays a neutral
|
||||
attribute marker — no card-level highlight like .has-update. */
|
||||
.workflow-badge {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--badge-workflow-bg);
|
||||
color: var(--badge-workflow-text);
|
||||
font-size: 0.7rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px var(--badge-workflow-glow);
|
||||
border: 1px solid color-mix(in oklab, var(--badge-workflow-bg) 55%, transparent);
|
||||
}
|
||||
|
||||
.workflow-badge i {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
background: var(--lora-accent, #4f46e5);
|
||||
color: #fff;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
@@ -616,7 +616,7 @@ html[data-theme="dark"] .help-tooltip {
|
||||
/* Make the active duplicates button more prominent */
|
||||
#findDuplicatesBtn.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h) / 0.25);
|
||||
position: relative;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 10px;
|
||||
transition: var(--transition-base);
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* Header search - Centered with VS Code command palette style */
|
||||
@@ -354,7 +354,7 @@
|
||||
|
||||
.header-controls>div:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
color: var(--text-on-accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -572,7 +572,7 @@
|
||||
}
|
||||
|
||||
/* Dark mode: use each preset's dark-mode accent lightness for visibility.
|
||||
These match the --color-accent-l values from [data-theme="dark"][data-theme-preset="..."]
|
||||
These match the --color-accent-l values from :root[data-theme="dark"][data-theme-preset="..."]
|
||||
in tokens/colors.css so the swatch accurately previews what the theme looks like. */
|
||||
|
||||
[data-theme="dark"] .preset-swatch-default {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user