mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 13:42:12 -03:00
Compare commits
377 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c03aa1430 | ||
|
|
5376fd8724 | ||
|
|
6dea9a76bc | ||
|
|
d73903e82e | ||
|
|
4862419b61 | ||
|
|
e6e7df7454 | ||
|
|
30f9e3e2ec | ||
|
|
707d0cb8a4 | ||
|
|
56ea7594ce | ||
|
|
389e46c251 | ||
|
|
6db17e682a | ||
|
|
94e0308a12 | ||
|
|
1f9f821576 | ||
|
|
57933dfba6 | ||
|
|
c50bee7757 | ||
|
|
4e3ee843f9 | ||
|
|
7e40f6fcb9 | ||
|
|
7976956b6b | ||
|
|
adce5293d5 | ||
|
|
c2db5eb6df | ||
|
|
f958ecdf18 | ||
|
|
ef0bcc6cf1 | ||
|
|
285428ad3a | ||
|
|
ee18cff3d9 | ||
|
|
1be3235564 | ||
|
|
a92883509a | ||
|
|
ce42d83ce9 | ||
|
|
077cf7b574 | ||
|
|
b99d78bda6 | ||
|
|
39586f4a20 | ||
|
|
4ef750b206 | ||
|
|
9d3d93823d | ||
|
|
45c1113b72 | ||
|
|
e10717dcda | ||
|
|
315ab6f70b | ||
|
|
cf4d654c4b | ||
|
|
569c829709 | ||
|
|
de05b59f29 | ||
|
|
70a282a6c0 | ||
|
|
b10bcf7e78 | ||
|
|
5fb10263f3 | ||
|
|
9e76c9783e | ||
|
|
7770976513 | ||
|
|
dc1f7ab6fe | ||
|
|
32b1d6c561 | ||
|
|
5264e49f2a | ||
|
|
ce3adaf831 | ||
|
|
e2f3e57f5c | ||
|
|
5c2349ff42 | ||
|
|
50eee8c373 | ||
|
|
f89b792535 | ||
|
|
6d0ea2841c | ||
|
|
98678a8698 | ||
|
|
5326fa2970 | ||
|
|
90547670a2 | ||
|
|
4753206c52 | ||
|
|
613aa3b1c3 | ||
|
|
a6b704d4b4 | ||
|
|
227d06c736 | ||
|
|
8508763831 | ||
|
|
136d3153fa | ||
|
|
49bdf77040 | ||
|
|
f4dcd89835 | ||
|
|
139e915711 | ||
|
|
22eda58074 | ||
|
|
fb91cf4df2 | ||
|
|
e0332571da | ||
|
|
2d4bc47746 | ||
|
|
38e766484e | ||
|
|
b5ee4a6408 | ||
|
|
7892df21ec | ||
|
|
188fe407b6 | ||
|
|
600afdcd92 | ||
|
|
994fa4bd43 | ||
|
|
51098f2829 | ||
|
|
795b9e8418 | ||
|
|
9ca2b9dd56 | ||
|
|
d77b6d78b7 | ||
|
|
427e7a36d5 | ||
|
|
c90306cc9b | ||
|
|
5fe0660c64 | ||
|
|
2abb5bf122 | ||
|
|
bb65527469 | ||
|
|
d9a6db3359 | ||
|
|
58cafdb713 | ||
|
|
0594e278b6 | ||
|
|
807425f12a | ||
|
|
aa4b1ccc25 | ||
|
|
58255ec28b | ||
|
|
d62b84693d | ||
|
|
df75c7e68d | ||
|
|
c5c7fdf54f | ||
|
|
49e0deeff3 | ||
|
|
0c20701bef | ||
|
|
faa26651dd | ||
|
|
2eae8a7729 | ||
|
|
dde2b2a960 | ||
|
|
4a9089d3dd | ||
|
|
3244a5f1a1 | ||
|
|
449c1e9d10 | ||
|
|
d0aa916683 | ||
|
|
13433f8cd2 | ||
|
|
8d336320c0 | ||
|
|
d945c58d51 | ||
|
|
acaf122346 | ||
|
|
713759b411 | ||
|
|
c5175bb870 | ||
|
|
e63ef8d031 | ||
|
|
e043537241 | ||
|
|
46126f9950 | ||
|
|
f4eb916914 | ||
|
|
49b9b7a5ea | ||
|
|
9b1a9ee071 | ||
|
|
0b8f137a1b | ||
|
|
6148a12301 | ||
|
|
fadbf21b4f | ||
|
|
c38a06937d | ||
|
|
1a34403b0e | ||
|
|
e4d58d0f60 | ||
|
|
4e4ea85cc3 | ||
|
|
f7a856349a | ||
|
|
15edd7a42c | ||
|
|
46243a236d | ||
|
|
6f382e587a | ||
|
|
bf3d706bf4 | ||
|
|
cdf21e813c | ||
|
|
10f5588e4a | ||
|
|
0ecbdf6f39 | ||
|
|
61101a7ad0 | ||
|
|
6d9be814a5 | ||
|
|
52bf93e430 | ||
|
|
00fade756c | ||
|
|
3c0feb23ba | ||
|
|
3627840fe9 | ||
|
|
bbdc1bba87 | ||
|
|
21a1bc1a01 | ||
|
|
0968698804 | ||
|
|
a5b2e9b0bf | ||
|
|
5a6ff444b9 | ||
|
|
3bb240d3c1 | ||
|
|
ee0d241c75 | ||
|
|
321ff72953 | ||
|
|
412f1e62a1 | ||
|
|
8901b32a55 | ||
|
|
8ab6cc72ad | ||
|
|
52e671638b | ||
|
|
a3070f8d82 | ||
|
|
3fde474583 | ||
|
|
1454991d6d | ||
|
|
4398851bb9 | ||
|
|
5173aa6c20 | ||
|
|
3d98572a62 | ||
|
|
c48095d9c6 | ||
|
|
1e4d1b8f15 | ||
|
|
8c037465ba | ||
|
|
055c1ca0d4 | ||
|
|
27370df93a | ||
|
|
60d23aa238 | ||
|
|
5e441d9c4f | ||
|
|
eb76468280 | ||
|
|
01bbaa31a8 | ||
|
|
bddf023dc4 | ||
|
|
8e69a247ed | ||
|
|
97141b01e1 | ||
|
|
acf610ddff | ||
|
|
a9a6f66035 | ||
|
|
0040863a03 | ||
|
|
4ab86b4ae2 | ||
|
|
b32b4b4042 | ||
|
|
4e552dcf3e | ||
|
|
8f4c02efdc | ||
|
|
b77c596f3a | ||
|
|
181f0b5626 | ||
|
|
480e5d966f | ||
|
|
e8636b949d | ||
|
|
8ea369db47 | ||
|
|
ec9b37eb53 | ||
|
|
b0847f6b87 | ||
|
|
84d10b1f3b | ||
|
|
4fdc97d062 | ||
|
|
5fe5e7ea54 | ||
|
|
7be1a2bd65 | ||
|
|
87842385c6 | ||
|
|
1dc189eb39 | ||
|
|
6120922204 | ||
|
|
ddb30dbb17 | ||
|
|
1e8bd88e28 | ||
|
|
c3a66ecf28 | ||
|
|
1f60160e8b | ||
|
|
7d560bf07a | ||
|
|
47da9949d9 | ||
|
|
68c0a5ba71 | ||
|
|
1aa81c803b | ||
|
|
8f5e134d3e | ||
|
|
ef03a2a917 | ||
|
|
e275968553 | ||
|
|
76d3aa2b5b | ||
|
|
c9a65c7347 | ||
|
|
f542ade628 | ||
|
|
d2c2bfbe6a | ||
|
|
2b6910bd55 | ||
|
|
b1dd733493 | ||
|
|
5dcf0a1e48 | ||
|
|
cf357b57fc | ||
|
|
4e1773833f | ||
|
|
8cf762ffd3 | ||
|
|
d997eaa429 | ||
|
|
8e51f0f19f | ||
|
|
f0e246b4ac | ||
|
|
a232997a79 | ||
|
|
08a449db99 | ||
|
|
0c023c9888 | ||
|
|
0ad92d00b3 | ||
|
|
a726cbea1e | ||
|
|
c53fa8692b | ||
|
|
3118f3b43c | ||
|
|
9199950b74 | ||
|
|
4c7e31687b | ||
|
|
75e207b520 | ||
|
|
631289b75e | ||
|
|
1b958d0a5d | ||
|
|
35fdf9020d | ||
|
|
45926b1dca | ||
|
|
686ba5024d | ||
|
|
cf375c7c86 | ||
|
|
5e53d76f44 | ||
|
|
7757f72859 | ||
|
|
c8cc584049 | ||
|
|
2cdd269bba | ||
|
|
d2d97ae5bb | ||
|
|
d08d77c555 | ||
|
|
92f8d2139a | ||
|
|
50f2c2dfe6 | ||
|
|
3539c453d3 | ||
|
|
1631122f95 | ||
|
|
8fcb979544 | ||
|
|
8a5af0b7f3 | ||
|
|
cb1f08d556 | ||
|
|
1150267765 | ||
|
|
5c1252548d | ||
|
|
3c7cdf5db8 | ||
|
|
9ac4203b1c | ||
|
|
d0800510db | ||
|
|
f8ba551cc4 | ||
|
|
413444500e | ||
|
|
e21d5835ec | ||
|
|
f2f354e478 | ||
|
|
b195d4569c | ||
|
|
3b77fed72d | ||
|
|
fc64e97f92 | ||
|
|
1da0434454 | ||
|
|
cf2fe40612 | ||
|
|
8f46433ff7 | ||
|
|
f3be3ae269 | ||
|
|
cfec5447d3 | ||
|
|
2d36b461cf | ||
|
|
5e23e4b13d | ||
|
|
badae2e8b3 | ||
|
|
9e64531de6 | ||
|
|
fdec8d283c | ||
|
|
9abedbf7cb | ||
|
|
66004c1cdc | ||
|
|
5b564cd8a3 | ||
|
|
2e79970e6e | ||
|
|
67c82ba6ea | ||
|
|
98425f37b8 | ||
|
|
9d22dd3465 | ||
|
|
837138db49 | ||
|
|
d43d992362 | ||
|
|
16b611cb7e | ||
|
|
8dde2d5e0d | ||
|
|
22b0b2bd24 | ||
|
|
056f727bfd | ||
|
|
0aa6c53c1f | ||
|
|
d9b0660611 | ||
|
|
d01666f4e2 | ||
|
|
51bee87cd0 | ||
|
|
3041b443e5 | ||
|
|
d95e6c939b | ||
|
|
fd38c63b35 | ||
|
|
b69c24ae14 | ||
|
|
65a0c00e33 | ||
|
|
b12a5ef133 | ||
|
|
9e1b92c26e | ||
|
|
3922aec36e | ||
|
|
41cca8e56d | ||
|
|
2d37a7341a | ||
|
|
40e3c6134c | ||
|
|
edddd47a1e | ||
|
|
4ea6f38645 | ||
|
|
40d998a026 | ||
|
|
3af8f151ac | ||
|
|
e066fa6873 | ||
|
|
6bd94269d4 | ||
|
|
c90edec18a | ||
|
|
cbb302614c | ||
|
|
c54611a11b | ||
|
|
88f249649a | ||
|
|
fe9fbdb93c | ||
|
|
28bc966b76 | ||
|
|
77bbf85b52 | ||
|
|
3b1990e97a | ||
|
|
375b5a49f3 | ||
|
|
392c157cb5 | ||
|
|
6f5bf4b582 | ||
|
|
2e3f48ebb7 | ||
|
|
e4a2c518bb | ||
|
|
f19fb68b4c | ||
|
|
9121c12a2c | ||
|
|
d0fe28cfe2 | ||
|
|
656e3e43be | ||
|
|
c2c1772371 | ||
|
|
88d5caf642 | ||
|
|
1684978693 | ||
|
|
8e4927600f | ||
|
|
4d72dc57e7 | ||
|
|
e7316b3389 | ||
|
|
e17b374606 | ||
|
|
141f83065f | ||
|
|
6381dbafc1 | ||
|
|
fc9db4510f | ||
|
|
66abf736c9 | ||
|
|
af713470c1 | ||
|
|
93a51d2bcb | ||
|
|
3f3e06de8a | ||
|
|
7315aac9d8 | ||
|
|
d933308a6f | ||
|
|
3baf93dcc5 | ||
|
|
6ba14bd8fe | ||
|
|
7499570766 | ||
|
|
003ee55a75 | ||
|
|
b0cc42ef1f | ||
|
|
23679ec3f5 | ||
|
|
da52e5b9dd | ||
|
|
c4e357793f | ||
|
|
6c3424029c | ||
|
|
dd9e6a5b69 | ||
|
|
095320ef72 | ||
|
|
35f7674bcd | ||
|
|
26b36c123d | ||
|
|
c85e694c1d | ||
|
|
ec05282db6 | ||
|
|
3d6f9b226f | ||
|
|
eda6df4a5d | ||
|
|
d504f89f6a | ||
|
|
14c468f2a2 | ||
|
|
2a99b0e46f | ||
|
|
ae8914f5c8 | ||
|
|
0c9f8971ce | ||
|
|
d7a75ea4e5 | ||
|
|
3ad8d8b17c | ||
|
|
39225dc204 | ||
|
|
4fb69f7d89 | ||
|
|
0890c6ad24 | ||
|
|
dd81809589 | ||
|
|
f0672beb46 | ||
|
|
cc5301e710 | ||
|
|
9d5ec43c4e | ||
|
|
6d41211b07 | ||
|
|
d58b61eed5 | ||
|
|
4b53d98bfc | ||
|
|
f51f354e48 | ||
|
|
59d027181d | ||
|
|
0d0988c090 | ||
|
|
dc2de50924 | ||
|
|
a3c28c1003 | ||
|
|
f4b7c9a138 | ||
|
|
6b860b5f29 | ||
|
|
37dfcd6abd | ||
|
|
bc2fca3a4f | ||
|
|
f8ef159656 | ||
|
|
b2b8a9d37e | ||
|
|
15ae4031b7 | ||
|
|
688976ce3b | ||
|
|
a548af01dc | ||
|
|
0dd52eceb3 | ||
|
|
b8c6cf4ac1 |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,5 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: PixelPawsAI
|
||||
ko_fi: pixelpawsai
|
||||
custom: ['paypal.me/pixelpawsai']
|
||||
patreon: PixelPawsAI
|
||||
custom: ['paypal.me/pixelpawsai', 'https://afdian.com/a/pixelpawsai']
|
||||
|
||||
69
.github/workflows/backend-tests.yml
vendored
Normal file
69
.github/workflows/backend-tests.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Backend Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- 'py/**'
|
||||
- 'standalone.py'
|
||||
- 'tests/**'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-dev.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'pytest.ini'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'py/**'
|
||||
- 'standalone.py'
|
||||
- 'tests/**'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-dev.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'pytest.ini'
|
||||
- '.github/workflows/backend-tests.yml'
|
||||
|
||||
jobs:
|
||||
pytest:
|
||||
name: Run pytest with coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: |
|
||||
requirements.txt
|
||||
requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run pytest with coverage
|
||||
env:
|
||||
COVERAGE_FILE: coverage/backend/.coverage
|
||||
run: |
|
||||
mkdir -p coverage/backend
|
||||
python -m pytest \
|
||||
--cov=py \
|
||||
--cov=standalone \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=xml:coverage/backend/coverage.xml \
|
||||
--cov-report=html:coverage/backend/html \
|
||||
--cov-report=json:coverage/backend/coverage.json
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: coverage/backend
|
||||
if-no-files-found: warn
|
||||
52
.github/workflows/frontend-tests.yml
vendored
Normal file
52
.github/workflows/frontend-tests.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Frontend Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.js'
|
||||
- 'tests/frontend/**'
|
||||
- 'static/js/**'
|
||||
- 'scripts/run_frontend_coverage.js'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'vitest.config.js'
|
||||
- 'tests/frontend/**'
|
||||
- 'static/js/**'
|
||||
- 'scripts/run_frontend_coverage.js'
|
||||
- '.github/workflows/frontend-tests.yml'
|
||||
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run Vitest with coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-coverage
|
||||
path: coverage/frontend
|
||||
if-no-files-found: warn
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ cache/
|
||||
civitai/
|
||||
node_modules/
|
||||
coverage/
|
||||
.coverage
|
||||
model_cache/
|
||||
|
||||
17
AGENTS.md
17
AGENTS.md
@@ -1,19 +1,22 @@
|
||||
# Repository Guidelines
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
ComfyUI LoRA Manager pairs a Python backend with lightweight browser scripts. Backend modules live in `py/`, organized by responsibility: HTTP entry points under `routes/`, feature logic in `services/`, reusable helpers within `utils/`, and custom nodes in `nodes/`. Front-end widgets that extend the ComfyUI interface sit in `web/comfyui/`, while static images and templates are in `static/` and `templates/`. Shared localization files are stored in `locales/`, with workflow examples under `example_workflows/`. Tests currently reside alongside the source (`test_i18n.py`) until a dedicated `tests/` folder is introduced.
|
||||
ComfyUI LoRA Manager pairs a Python backend with browser-side widgets. Backend modules live in <code>py/</code> with HTTP entry points in <code>py/routes/</code>, feature logic in <code>py/services/</code>, shared helpers in <code>py/utils/</code>, and custom nodes in <code>py/nodes/</code>. UI scripts extend ComfyUI from <code>web/comfyui/</code>, while deploy-ready assets remain in <code>static/</code> and <code>templates/</code>. Localization files live in <code>locales/</code>, example workflows in <code>example_workflows/</code>, and interim tests such as <code>test_i18n.py</code> sit beside their source until a dedicated <code>tests/</code> tree lands.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Install dependencies with `pip install -r requirements.txt` from the repo root. Launch the standalone server for iterative work via `python standalone.py --port 8188`; ComfyUI users can also load the extension directly through ComfyUI's custom node manager. Run backend checks with `python -m pytest test_i18n.py`, and target new test files explicitly (e.g. `python -m pytest tests/test_recipes.py` once added). Use `python scripts/sync_translation_keys.py` to reconcile locale keys after updating UI strings.
|
||||
- <code>pip install -r requirements.txt</code> installs backend dependencies.
|
||||
- <code>python standalone.py --port 8188</code> launches the standalone server for iterative development.
|
||||
- <code>python -m pytest test_i18n.py</code> runs the current regression suite; target new files explicitly, e.g. <code>python -m pytest tests/test_recipes.py</code>.
|
||||
- <code>python scripts/sync_translation_keys.py</code> synchronizes locale keys after UI string updates.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Follow PEP 8 with four-space indentation and descriptive snake_case module/function names, mirroring files such as `py/services/settings_manager.py`. Classes remain PascalCase, constants UPPER_SNAKE_CASE, and loggers retrieved via `logging.getLogger(__name__)`. Prefer explicit type hints for new public APIs and docstrings that clarify side effects. JavaScript in `web/comfyui/` is modern ES modules; keep imports relative, favor camelCase functions, and mirror existing file suffixes like `_widget.js` for UI components.
|
||||
Follow PEP 8 with four-space indentation and descriptive snake_case file and function names such as <code>settings_manager.py</code>. Classes stay PascalCase, constants in UPPER_SNAKE_CASE, and loggers retrieved via <code>logging.getLogger(__name__)</code>. Prefer explicit type hints and docstrings on public APIs. JavaScript under <code>web/comfyui/</code> uses ES modules with camelCase helpers and the <code>_widget.js</code> suffix for UI components.
|
||||
|
||||
## Testing Guidelines
|
||||
Extend pytest coverage by co-locating tests near the code under test or in `tests/` with names like `test_<feature>.py`. When introducing new routes or services, add regression cases that mock ComfyUI dependencies (see the standalone mocking helpers in `standalone.py`). Prioritize deterministic fixtures for filesystem interactions and ensure translations include coverage when adding new locale keys. Always run `python -m pytest` before submitting work.
|
||||
Pytest powers backend tests. Name modules <code>test_<feature>.py</code> and keep them near the code or in a future <code>tests/</code> package. Mock ComfyUI dependencies through helpers in <code>standalone.py</code>, keep filesystem fixtures deterministic, and ensure translations are covered. Run <code>python -m pytest</code> before submitting changes.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Commits follow the conventional pattern seen in `git log` (`feat(scope):`, `fix(scope):`, `chore(scope):`). Keep messages imperative and scoped to a single change. Pull requests should summarize the problem, detail the solution, list manual test evidence, and link any GitHub issues. Include UI screenshots or GIFs when front-end behavior changes, and call out migration steps (e.g., settings updates) in the PR description.
|
||||
Commits follow the conventional format, e.g. <code>feat(settings): add default model path</code>, and should stay focused on a single concern. Pull requests must outline the problem, summarize the solution, list manual verification steps (server run, targeted pytest), and link related issues. Include screenshots or GIFs for UI or locale updates and call out migration steps such as <code>settings.json</code> adjustments.
|
||||
|
||||
## Configuration & Localization Tips
|
||||
Sample configuration defaults live in `settings.json.example`; copy it to `settings.json` and adjust model directories before running the standalone server. Whenever you add UI text, update `locales/<lang>.json` and run the translation sync script. Store reference assets in `civitai/` or `docs/` rather than mixing them with production templates, keeping the runtime folders (`static/`, `templates/`) deploy-ready.
|
||||
Copy <code>settings.json.example</code> to <code>settings.json</code> and adapt model directories before running the standalone server. Store reference assets in <code>civitai/</code> or <code>docs/</code> to keep runtime directories deploy-ready. Whenever UI text changes, update every <code>locales/<lang>.json</code> file and rerun the translation sync script so ComfyUI surfaces localized strings.
|
||||
|
||||
103
IFLOW.md
Normal file
103
IFLOW.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# ComfyUI LoRA Manager - iFlow 上下文
|
||||
|
||||
## 项目概述
|
||||
|
||||
ComfyUI LoRA Manager 是一个全面的工具集,用于简化 ComfyUI 中 LoRA 模型的组织、下载和应用。它提供了强大的功能,如配方管理、检查点组织和一键工作流集成,使模型操作更快、更流畅、更简单。
|
||||
|
||||
该项目是一个 Python 后端与 JavaScript 前端结合的 Web 应用程序,既可以作为 ComfyUI 的自定义节点运行,也可以作为独立应用程序运行。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\
|
||||
├── py/ # Python 后端代码
|
||||
│ ├── config.py # 全局配置
|
||||
│ ├── lora_manager.py # 主入口点
|
||||
│ ├── controllers/ # 控制器
|
||||
│ ├── metadata_collector/ # 元数据收集器
|
||||
│ ├── middleware/ # 中间件
|
||||
│ ├── nodes/ # ComfyUI 节点
|
||||
│ ├── recipes/ # 配方相关
|
||||
│ ├── routes/ # API 路由
|
||||
│ ├── services/ # 业务逻辑服务
|
||||
│ ├── utils/ # 工具函数
|
||||
│ └── validators/ # 验证器
|
||||
├── static/ # 静态资源 (CSS, JS, 图片)
|
||||
├── templates/ # HTML 模板
|
||||
├── locales/ # 国际化文件
|
||||
├── tests/ # 测试代码
|
||||
├── standalone.py # 独立模式入口
|
||||
├── requirements.txt # Python 依赖
|
||||
├── package.json # Node.js 依赖和脚本
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 后端 (Python)
|
||||
|
||||
- **主入口**: `py/lora_manager.py` 和 `standalone.py`
|
||||
- **配置**: `py/config.py` 管理全局配置和路径
|
||||
- **路由**: `py/routes/` 目录下包含各种 API 路由
|
||||
- **服务**: `py/services/` 目录下包含业务逻辑,如模型扫描、下载管理等
|
||||
- **模型管理**: 使用 `ModelServiceFactory` 来管理不同类型的模型 (LoRA, Checkpoint, Embedding)
|
||||
|
||||
### 前端 (JavaScript)
|
||||
|
||||
- **构建工具**: 使用 Node.js 和 npm 进行依赖管理和测试
|
||||
- **测试**: 使用 Vitest 进行前端测试
|
||||
|
||||
## 构建和运行
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# Python 依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Node.js 依赖 (用于测试)
|
||||
npm install
|
||||
```
|
||||
|
||||
### 运行 (ComfyUI 模式)
|
||||
|
||||
作为 ComfyUI 的自定义节点安装后,在 ComfyUI 中启动即可。
|
||||
|
||||
### 运行 (独立模式)
|
||||
|
||||
```bash
|
||||
# 使用默认配置运行
|
||||
python standalone.py
|
||||
|
||||
# 指定主机和端口
|
||||
python standalone.py --host 127.0.0.1 --port 9000
|
||||
```
|
||||
|
||||
### 测试
|
||||
|
||||
#### 后端测试
|
||||
|
||||
```bash
|
||||
# 安装开发依赖
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# 运行测试
|
||||
pytest
|
||||
```
|
||||
|
||||
#### 前端测试
|
||||
|
||||
```bash
|
||||
# 运行测试
|
||||
npm run test
|
||||
|
||||
# 运行测试并生成覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 开发约定
|
||||
|
||||
- **代码风格**: Python 代码应遵循 PEP 8 规范
|
||||
- **测试**: 新功能应包含相应的单元测试
|
||||
- **配置**: 使用 `settings.json` 文件进行用户配置
|
||||
- **日志**: 使用 Python 标准库 `logging` 模块进行日志记录
|
||||
62
README.md
62
README.md
@@ -34,7 +34,24 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.9.3
|
||||
### v0.9.9
|
||||
* **Check for Updates Feature** - Users can now check for updates for all models or selected models in bulk mode. Models with available updates will display an "update available" badge on their model card, and users can filter to show only models with updates.
|
||||
* **Model Versions Management** - Added a new Versions tab in the model modal that centralizes all versions of a model, providing download, delete, and ignore update functions.
|
||||
* **Send Checkpoint to ComfyUI** - Users can now click the send button on a checkpoint card to send the checkpoint directly to the current workflow's checkpoint or diffusion model loader node in ComfyUI.
|
||||
* **Customizable Model Card Display** - Added a new setting that allows users to choose whether to display the model name or filename on model cards.
|
||||
* **New Path Template Placeholders** - Added new path template placeholders: `{model_name}` and `{version_name}` for more flexible organization.
|
||||
* **ComfyUI Auto Path Correction Setting** - Added a new setting within ComfyUI to enable or disable the auto path correction feature.
|
||||
|
||||
### v0.9.8
|
||||
* **Full CivArchive API Support** - Added complete support for the CivArchive API as a fallback metadata source beyond Civitai API. Models deleted from Civitai can now still retrieve metadata through the CivArchive API.
|
||||
* **Download Models from CivArchive** - Added support for downloading models directly from CivArchive, similar to downloading from Civitai. Simply click the Download button and paste the model URL to download the corresponding model.
|
||||
* **Custom Priority Tags** - Introduced Custom Priority Tags feature, allowing users to define custom priority tags. These tags will appear as suggestions when editing tags or during auto organization/download using default paths, providing more precise and controlled folder organization. [Guide](https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide)
|
||||
* **Drag and Drop Tag Reordering** - Added drag and drop functionality to reorder tags in the tags edit mode for improved usability.
|
||||
* **Download Control in Example Images Panel** - Added stop control in the Download Example Images Panel for better download management.
|
||||
* **Prompt (LoraManager) Node with Autocomplete** - Added new Prompt (LoraManager) node with autocomplete feature for adding embeddings.
|
||||
* **Lora Manager Nodes in Subgraphs** - Lora Manager nodes now support being placed within subgraphs for more flexible workflow organization.
|
||||
|
||||
### v0.9.6
|
||||
* **Metadata Archive Database Support** - Added the ability to download and utilize a metadata archive database, enabling access to metadata for models that have been deleted from CivitAI.
|
||||
* **App-Level Proxy Settings** - Introduced support for configuring a global proxy within the application, making it easier to use the manager behind network restrictions.
|
||||
* **Bug Fixes** - Various bug fixes for improved stability and reliability.
|
||||
@@ -139,9 +156,10 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.2/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
|
||||
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
||||
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
||||
4. Run run.bat
|
||||
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
|
||||
|
||||
@@ -209,7 +227,7 @@ You can combine multiple patterns to create detailed, organized filenames for yo
|
||||
You can now run LoRA Manager independently from ComfyUI:
|
||||
|
||||
1. **For ComfyUI users**:
|
||||
- Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file.
|
||||
- Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file located in your user settings folder (see paths above).
|
||||
- Make sure dependencies are installed: `pip install -r requirements.txt`
|
||||
- From your ComfyUI root directory, run:
|
||||
```bash
|
||||
@@ -222,8 +240,9 @@ You can now run LoRA Manager independently from ComfyUI:
|
||||
```
|
||||
|
||||
2. **For non-ComfyUI users**:
|
||||
- Copy the provided `settings.json.example` file to create a new file named `settings.json`
|
||||
- Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Copy the provided `settings.json.example` file to create a new file named `settings.json`. Update the API key, optional language, and folder paths only—the library registry is created automatically when LoRA Manager starts.
|
||||
- Edit `settings.json` to include your correct model folder paths and CivitAI API key (you can leave the defaults until ready to configure them)
|
||||
- Enable portable mode by setting `"use_portable_settings": true` if you prefer LoRA Manager to read and write the `settings.json` located in the project directory.
|
||||
- Install required dependencies: `pip install -r requirements.txt`
|
||||
- Run standalone mode:
|
||||
```bash
|
||||
@@ -231,8 +250,37 @@ You can now run LoRA Manager independently from ComfyUI:
|
||||
```
|
||||
- Access the interface through your browser at: `http://localhost:8188/loras`
|
||||
|
||||
> **Note:** Existing installations automatically migrate the legacy `settings.json` from the plugin folder to the user settings directory the first time you launch this version.
|
||||
|
||||
This standalone mode provides a lightweight option for managing your model and recipe collection without needing to run the full ComfyUI environment, making it useful even for users who primarily use other stable diffusion interfaces.
|
||||
|
||||
## Testing & Coverage
|
||||
|
||||
### Backend
|
||||
|
||||
Install the development dependencies and run pytest with coverage reports:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-dev.txt
|
||||
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov=py \
|
||||
--cov=standalone \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html:coverage/backend/html \
|
||||
--cov-report=xml:coverage/backend/coverage.xml \
|
||||
--cov-report=json:coverage/backend/coverage.json
|
||||
```
|
||||
|
||||
HTML, XML, and JSON artifacts are stored under `coverage/backend/` so you can inspect hot spots locally or from CI artifacts.
|
||||
|
||||
### Frontend
|
||||
|
||||
Run the Vitest coverage suite to analyze widget hot spots:
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -2,6 +2,7 @@ try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.lora_manager import LoraManager
|
||||
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
|
||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||
from .py.nodes.prompt import PromptLoraManager
|
||||
from .py.nodes.lora_stacker import LoraStacker
|
||||
from .py.nodes.save_image import SaveImage
|
||||
from .py.nodes.debug_metadata import DebugMetadata
|
||||
@@ -17,6 +18,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
|
||||
if str(package_root) not in sys.path:
|
||||
sys.path.append(str(package_root))
|
||||
|
||||
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
|
||||
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
|
||||
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
|
||||
@@ -29,6 +31,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
|
||||
init_metadata_collector = importlib.import_module("py.metadata_collector").init
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
PromptLoraManager.NAME: PromptLoraManager,
|
||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
|
||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||
|
||||
34
docs/architecture/multi_library_design.md
Normal file
34
docs/architecture/multi_library_design.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Multi-Library Management for Standalone Mode
|
||||
|
||||
## 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`.
|
||||
- **Persistent caches**: Every library must have its own SQLite persistent model cache so that metadata generated for one library does not leak into another.
|
||||
- **Backward compatibility**: Existing single-library setups should continue to work. When no multi-library configuration is provided, the application should behave exactly as before.
|
||||
|
||||
## Proposed Design
|
||||
1. **Library registry**
|
||||
- Extend the standalone configuration to hold a list of libraries, each identified by a unique name.
|
||||
- Each entry stores the folder path configuration plus any library-scoped metadata (e.g. creation time, display name).
|
||||
- The active library key is stored separately to allow quick switching without rewriting the full config.
|
||||
2. **Settings management**
|
||||
- Update `settings_manager` to load and persist the library registry. When a library is activated, hydrate the in-memory settings object with that library's folder configuration.
|
||||
- Provide helper methods for creating, renaming, and deleting libraries, ensuring validation for duplicate names and path collisions.
|
||||
- Continue writing the active library settings to `settings.json` for compatibility, while storing the registry in a new section such as `libraries`.
|
||||
3. **Persistent model cache**
|
||||
- Derive the SQLite file path from the active library, e.g. `model_cache_<library>.sqlite` or a nested directory structure like `model_cache/<library>/models.sqlite`.
|
||||
- Update `PersistentModelCache` so it resolves the database path dynamically whenever the active library changes. Ensure connections are closed before switching to avoid locking issues.
|
||||
- Migrate existing single cache files by treating them as the default library's cache.
|
||||
4. **Model scanning workflow**
|
||||
- Modify `ModelScanner` and related services to react to library switches by clearing in-memory caches, re-reading folder paths, and rehydrating metadata from the library-specific SQLite cache.
|
||||
- Provide API endpoints in standalone mode to list libraries, activate one, and trigger a rescan.
|
||||
5. **UI/UX considerations**
|
||||
- In the standalone UI, introduce a library selector component that surfaces available libraries and offers quick switching.
|
||||
- Offer feedback when switching libraries (e.g. spinner while rescanning) and guard destructive actions with confirmation prompts.
|
||||
|
||||
## Implementation Notes
|
||||
- **Data migration**: On startup, detect if the old `settings.json` structure is present. If so, create a default library entry using the current folder paths and point the active library to it.
|
||||
- **Thread safety**: Ensure that any long-running scans are cancelled or awaited before switching libraries to prevent race conditions in cache writes.
|
||||
- **Testing**: Add unit tests for the settings manager to cover library CRUD operations and cache path resolution. Include integration tests that simulate switching libraries and verifying that the correct models are loaded.
|
||||
- **Documentation**: Update user guides to explain how to define libraries, switch between them, and where the new cache files are stored.
|
||||
- **Extensibility**: Keep the design open to future per-library settings (e.g. auto-refresh intervals, metadata overrides) by storing library data as objects instead of flat maps.
|
||||
46
docs/custom_priority_tags_format.md
Normal file
46
docs/custom_priority_tags_format.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Custom Priority Tag Format Proposal
|
||||
|
||||
To support user-defined priority tags with flexible aliasing across different model types, the configuration will be stored as editable strings. The format balances readability with enough structure for parsing on both the backend and frontend.
|
||||
|
||||
## Format Overview
|
||||
|
||||
- Each model type is declared on its own line: `model_type: entries`.
|
||||
- Entries are comma-separated and ordered by priority from highest to lowest.
|
||||
- An entry may be a single canonical tag (e.g., `realistic`) or a canonical tag with aliases.
|
||||
- Canonical tags define the final folder name that should be used when matching that entry.
|
||||
- Aliases are enclosed in parentheses and separated by `|` (vertical bar).
|
||||
- All matching is case-insensitive; stored canonical names preserve the user-specified casing for folder creation and UI suggestions.
|
||||
|
||||
### Grammar
|
||||
|
||||
```
|
||||
priority-config := model-config { "\n" model-config }
|
||||
model-config := model-type ":" entry-list
|
||||
model-type := <identifier without spaces>
|
||||
entry-list := entry { "," entry }
|
||||
entry := canonical [ "(" alias { "|" alias } ")" ]
|
||||
canonical := <tag text without parentheses or commas>
|
||||
alias := <tag text without parentheses, commas, or pipes>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
lora: celebrity(celeb|celebrity), stylized, character(char)
|
||||
checkpoint: realistic(realism|realistic), anime(anime-style|toon)
|
||||
embedding: face, celeb(celebrity|celeb)
|
||||
```
|
||||
|
||||
## Parsing Notes
|
||||
|
||||
- Whitespace around separators is ignored to make manual editing more forgiving.
|
||||
- Duplicate canonical tags within the same model type collapse to a single entry; the first definition wins.
|
||||
- Aliases map to their canonical tag. When generating folder names, the canonical form is used.
|
||||
- Tags that do not match any alias or canonical entry fall back to the first tag in the model's tag list, preserving current behavior.
|
||||
|
||||
## Usage
|
||||
|
||||
- **Backend:** Convert each model type's string into an ordered list of canonical tags with alias sets. During path generation, iterate by priority order and match tags against both canonical names and their aliases.
|
||||
- **Frontend:** Surface canonical tags as suggestions, optionally displaying aliases in tooltips or secondary text. Input validation should warn about duplicate aliases within the same model type.
|
||||
|
||||
This format allows users to customize priority tag handling per model type while keeping editing simple and avoiding proliferation of folder names through alias normalization.
|
||||
51
docs/frontend-dom-fixtures.md
Normal file
51
docs/frontend-dom-fixtures.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Frontend DOM Fixture Strategy
|
||||
|
||||
This guide outlines how to reproduce the markup emitted by the Django templates while running Vitest in jsdom. The aim is to make it straightforward to write integration-style unit tests for managers and UI helpers without having to duplicate template fragments inline.
|
||||
|
||||
## Loading Template Markup
|
||||
|
||||
Vitest executes inside Node, so we can read the same HTML templates that ship with the extension:
|
||||
|
||||
1. Use the helper utilities from `tests/frontend/utils/domFixtures.js` to read files under the `templates/` directory.
|
||||
2. Mount the returned markup into `document.body` (or any custom container) before importing the module under test so its query selectors resolve correctly.
|
||||
|
||||
```js
|
||||
import { renderTemplate } from '../utils/domFixtures.js'; // adjust the relative path to your spec
|
||||
|
||||
beforeEach(() => {
|
||||
renderTemplate('loras.html', {
|
||||
dataset: { page: 'loras' }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The helper ensures the dataset is applied to the container, which mirrors how Django sets `data-page` in production.
|
||||
|
||||
## Working with Partial Components
|
||||
|
||||
Many features are implemented as template partials located under `templates/components/`. When a test only needs a fragment (for example, the progress panel or context menu markup), load the component file directly:
|
||||
|
||||
```js
|
||||
const container = renderTemplate('components/progress_panel.html');
|
||||
|
||||
const progressPanel = container.querySelector('#progress-panel');
|
||||
```
|
||||
|
||||
This pattern avoids hand-written fixture strings and keeps the tests aligned with the actual markup.
|
||||
|
||||
## Resetting Between Tests
|
||||
|
||||
The shared Vitest setup clears `document.body` and storage APIs before each test. If a suite adds additional DOM nodes outside of the body or needs to reset custom attributes mid-test, use `resetDom()` exported from `domFixtures.js`.
|
||||
|
||||
```js
|
||||
import { resetDom } from '../utils/domFixtures.js';
|
||||
|
||||
afterEach(() => {
|
||||
resetDom();
|
||||
});
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Provide typed helpers for injecting mock script tags (e.g., replicating ComfyUI globals).
|
||||
- Compose higher-level fixtures that mimic specific pages (loras, checkpoints, recipes) once those managers receive dedicated suites.
|
||||
44
docs/frontend-filtering-test-matrix.md
Normal file
44
docs/frontend-filtering-test-matrix.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# LoRA & Checkpoints Filtering/Sorting Test Matrix
|
||||
|
||||
This matrix captures the scenarios that Phase 3 frontend tests should cover for the LoRA and Checkpoint managers. It focuses on how search, filter, sort, and duplicate badge toggles interact so future specs can share fixtures and expectations.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Components**: `PageControls`, `FilterManager`, `SearchManager`, and `ModelDuplicatesManager` wiring invoked through `CheckpointsPageManager` and `LorasPageManager`.
|
||||
- **Templates**: `templates/loras.html` and `templates/checkpoints.html` along with shared filter panel and toolbar partials.
|
||||
- **APIs**: Requests issued through `baseModelApi.fetchModels` (via `resetAndReload`/`refreshModels`) and duplicates badge updates.
|
||||
|
||||
## Shared Setup Considerations
|
||||
|
||||
1. Render full page templates using `renderLorasPage` / `renderCheckpointsPage` helpers before importing modules so DOM queries resolve.
|
||||
2. Stub storage helpers (`getStorageItem`, `setStorageItem`, `getSessionItem`, `setSessionItem`) to observe persistence behavior without mutating real storage.
|
||||
3. Mock `sidebarManager` to capture refresh calls triggered after sort/filter actions.
|
||||
4. Provide fake API implementations exposing `resetAndReload`, `refreshModels`, `fetchFromCivitai`, `toggleBulkMode`, and `clearCustomFilter` so control events remain asynchronous but deterministic.
|
||||
5. Supply a minimal `ModelDuplicatesManager` mock exposing `toggleDuplicateMode`, `checkDuplicatesCount`, and `updateDuplicatesBadgeAfterRefresh` to validate duplicate badge wiring.
|
||||
|
||||
## Scenario Matrix
|
||||
|
||||
| ID | Feature | Scenario | LoRAs Expectations | Checkpoints Expectations | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| F-01 | Search filter | Typing a query updates `pageState.filters.search`, persists to session, and triggers `resetAndReload` on submit | Validate `SearchManager` writes query and reloads via API stub; confirm LoRA cards pass query downstream | Same as LoRAs | Cover `enter` press and clicking search icon |
|
||||
| F-02 | Tag filter | Selecting a tag chip adds it to filters, applies active styling, and reloads results | Tag stored under `filters.tags`; `FilterManager.applyFilters` persists and triggers `resetAndReload(true)` | Same; ensure base model tag set is scoped to checkpoints dataset | Include removal path |
|
||||
| F-03 | Base model filter | Toggling base model checkboxes updates `filters.baseModel`, persists, and reloads | Ensure only LoRA-supported models show; toggle multi-select | Ensure SDXL/Flux base models appear as expected | Capture UI state restored from storage on next init |
|
||||
| F-04 | Favorites-only | Clicking favorites toggle updates session flag and calls `resetAndReload(true)` | Button gains `.active` class and API called | Same | Verify duplicates badge refresh when active |
|
||||
| F-05 | Sort selection | Changing sort select saves preference (legacy + new format) and reloads | Confirm `PageControls.saveSortPreference` invoked with option and API called | Same with checkpoints-specific defaults | Cover `convertLegacySortFormat` branch |
|
||||
| F-06 | Filter persistence | Re-initializing manager loads stored filters/sort and updates DOM | Filters pre-populate chips/checkboxes; favorites state restored | Same | Requires simulating repeated construction |
|
||||
| F-07 | Combined filters | Applying search + tag + base model yields aggregated query params for fetch | Assert API receives merged filter payload | Same | Validate toast messaging for active filters |
|
||||
| F-08 | Clearing filters | Using "Clear filters" resets state, storage, and reloads list | `FilterManager.clearFilters` empties `filters`, removes active class, shows toast | Same | Ensure favorites-only toggle unaffected |
|
||||
| F-09 | Duplicate badge toggle | Pressing "Find duplicates" toggles duplicate mode and updates badge counts post-refresh | `ModelDuplicatesManager.toggleDuplicateMode` invoked and badge refresh called after API rebuild | Same plus checkpoint-specific duplicate badge dataset | Connects to future duplicate-specific specs |
|
||||
| F-10 | Bulk actions menu | Opening bulk dropdown keeps filters intact and closes on outside click | Validate dropdown class toggling and no unintended reload | Same | Guard against regression when dropdown interacts with filters |
|
||||
|
||||
## Automation Coverage Status
|
||||
|
||||
- ✅ F-01 Search filter, F-02 Tag filter, F-03 Base model filter, F-04 Favorites-only toggle, F-05 Sort selection, and F-09 Duplicate badge toggle are covered by `tests/frontend/components/pageControls.filtering.test.js` for both LoRA and checkpoint pages.
|
||||
- ⏳ F-06 Filter persistence, F-07 Combined filters, F-08 Clearing filters, and F-10 Bulk actions remain to be automated alongside upcoming bulk mode refinements.
|
||||
|
||||
## Coverage Gaps & Follow-Ups
|
||||
|
||||
- Write Vitest suites that exercise the matrix for both managers, sharing fixtures through page helpers to avoid duplication.
|
||||
- Capture API parameter assertions by inspecting `baseModelApi.fetchModels` mocks rather than relying solely on state mutations.
|
||||
- Add regression cases for legacy storage migrations (old filter keys) once fixtures exist for older payloads.
|
||||
- Extend duplicate badge coverage with scenarios where `checkDuplicatesCount` signals zero duplicates versus pending calculations.
|
||||
@@ -8,16 +8,26 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Phase 0 | Establish baseline tooling | Add Node test runner, jsdom environment, and seed smoke tests | ✅ Complete | Vitest + jsdom configured, example state tests committed |
|
||||
| Phase 1 | Cover state management logic | Unit test selectors, derived data helpers, and storage utilities under `static/js/state` and `static/js/utils` | ✅ Complete | Storage helpers and state selectors now exercised via deterministic suites |
|
||||
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | 🟡 In Progress | AppCore initialization specs landed; expand to additional page wiring and scroll hooks |
|
||||
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ⚪ Not Started | Consider shared helpers for mocking API modules and storage |
|
||||
| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ⚪ Not Started | Evaluate Playwright component testing or happy-path DOM snapshots |
|
||||
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ⚪ Not Started | Align reporting directories with backend coverage for unified reporting |
|
||||
| Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | ✅ Complete | AppCore initialization + page feature suites now validate manager wiring, infinite scroll hooks, and onboarding gating |
|
||||
| Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ✅ Complete | LoRA/checkpoint suites expanded; embeddings + recipes managers now covered with initialization, filtering, and duplicate workflows |
|
||||
| Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ✅ Complete | Vitest DOM suites cover NSFW selector, recipe modal editing, and global context menus |
|
||||
| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ✅ Complete | CI workflow runs Vitest and aggregates V8 coverage into `coverage/frontend` via a dedicated script |
|
||||
|
||||
## Next Steps Checklist
|
||||
|
||||
- [x] Expand unit tests for `storageHelpers` covering migrations and namespace behavior.
|
||||
- [ ] Document DOM fixture strategy for reproducing template structures in tests.
|
||||
- [x] Document DOM fixture strategy for reproducing template structures in tests.
|
||||
- [x] Prototype AppCore initialization test that verifies manager bootstrapping with stubbed dependencies.
|
||||
- [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
|
||||
- [x] Add AppCore page feature suite exercising context menu creation and infinite scroll registration via DOM fixtures.
|
||||
- [x] Extend AppCore orchestration tests to cover manager wiring, bulk menu setup, and onboarding gating scenarios.
|
||||
- [x] Add interaction regression suites for context menus and recipe modals to complete Phase 4.
|
||||
- [x] Evaluate integrating coverage reporting once test surface grows (> 20 specs).
|
||||
- [x] Create shared fixtures for the loras and checkpoints pages once dedicated manager suites are added.
|
||||
- [x] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3.
|
||||
- [x] Implement LoRAs manager filtering/sorting specs for scenarios F-01–F-05 & F-09; queue remaining edge cases after duplicate/bulk flows stabilize.
|
||||
- [x] Implement checkpoints manager filtering/sorting specs for scenarios F-01–F-05 & F-09; cover remaining paths alongside bulk action work.
|
||||
- [x] Implement checkpoints page manager smoke tests covering initialization and duplicate badge wiring.
|
||||
- [x] Outline focused checkpoints scenarios (filtering, sorting, duplicate badge toggles) to feed into the shared test matrix.
|
||||
- [ ] Add duplicate badge regression coverage for zero/pending states after API refreshes.
|
||||
|
||||
Maintaining this roadmap alongside code changes will make it easier to append new automated test tasks and update their progress.
|
||||
|
||||
28
docs/library-switching.md
Normal file
28
docs/library-switching.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Library Switching and Preview Routes
|
||||
|
||||
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
|
||||
time. This allows the multi-library flow to update model roots without touching
|
||||
the aiohttp router, so previews remain available immediately after a switch.
|
||||
|
||||
## How the dynamic preview endpoint works
|
||||
|
||||
* `config.get_preview_static_url()` now returns `/api/lm/previews?path=<encoded>`
|
||||
for any preview path. The raw filesystem location is URL encoded so that it
|
||||
can be passed through the query string without leaking directory structure in
|
||||
the route itself.【F:py/config.py†L398-L404】
|
||||
* `PreviewRoutes` exposes the `/api/lm/previews` handler which validates the
|
||||
decoded path against the directories registered for the current library. The
|
||||
request is rejected if it falls outside those roots or if the file does not
|
||||
exist.【F:py/routes/preview_routes.py†L5-L21】【F:py/routes/handlers/preview_handlers.py†L9-L48】
|
||||
* `Config` keeps an up-to-date cache of allowed preview roots. Every time a
|
||||
library is applied the cache is rebuilt using the declared LoRA, checkpoint
|
||||
and embedding directories (including symlink targets). The validation logic
|
||||
checks preview requests against this cache.【F:py/config.py†L51-L68】【F:py/config.py†L180-L248】【F:py/config.py†L332-L346】
|
||||
|
||||
Both the ComfyUI runtime (`LoraManager.add_routes`) and the standalone launcher
|
||||
(`StandaloneLoraManager.add_routes`) register the new preview routes instead of
|
||||
mounting a static directory per root. Switching libraries therefore works
|
||||
without restarting the application, and preview URLs generated before or after a
|
||||
switch continue to resolve correctly.【F:py/lora_manager.py†L21-L82】【F:standalone.py†L302-L315】
|
||||
71
docs/priority_tags_help.md
Normal file
71
docs/priority_tags_help.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Priority Tags Configuration Guide
|
||||
|
||||
This guide explains how to tailor the tag priority order that powers folder naming and tag suggestions in the LoRA Manager. You only need to edit the comma-separated list of entries shown in the **Priority Tags** field for each model type.
|
||||
|
||||
## 1. Pick the Model Type
|
||||
|
||||
In the **Priority Tags** dialog you will find one tab per model type (LoRA, Checkpoint, Embedding). Select the tab you want to update; changes on one tab do not affect the others.
|
||||
|
||||
## 2. Edit the Entry List
|
||||
|
||||
Inside the textarea you will see a line similar to:
|
||||
|
||||
```
|
||||
character, concept, style(toon|toon_style)
|
||||
```
|
||||
|
||||
This entire line is the **entry list**. Replace it with your own ordered list.
|
||||
|
||||
### Entry Rules
|
||||
|
||||
Each entry is separated by a comma, in order from highest to lowest priority:
|
||||
|
||||
- **Canonical tag only:** `realistic`
|
||||
- **Canonical tag with aliases:** `character(char|chars)`
|
||||
|
||||
Aliases live inside `()` and are separated with `|`. The canonical name is what appears in folder names and UI suggestions when any of the aliases are detected. Matching is case-insensitive.
|
||||
|
||||
## Use `{first_tag}` in Path Templates
|
||||
|
||||
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the model’s own tags:
|
||||
|
||||
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
|
||||
- If no priority tags are found but the model has tags, the very first model tag is used.
|
||||
- If the model has no tags at all, the folder falls back to `no tags`.
|
||||
|
||||
### Example
|
||||
|
||||
With a template like `/{model_type}/{first_tag}` and the priority entry list `character(char|chars), style(anime|toon)`:
|
||||
|
||||
| Model Tags | Folder Name | Why |
|
||||
| --- | --- | --- |
|
||||
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
|
||||
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
|
||||
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
|
||||
| `[]` | `no tags` | Nothing to match, so the fallback is applied. |
|
||||
|
||||
## 3. Save the Settings
|
||||
|
||||
After editing the entry list, press **Enter** to save. Use **Shift+Enter** whenever you need a new line. Clicking outside the field also saves automatically. A success toast confirms the update.
|
||||
|
||||
## Examples
|
||||
|
||||
| Goal | Entry List |
|
||||
| --- | --- |
|
||||
| Prefer people over styles | `character, portraits, style(anime\|toon)` |
|
||||
| Group sci-fi variants | `sci-fi(scifi\|science_fiction), cyberpunk(cyber\|punk)` |
|
||||
| Alias shorthand tags | `realistic(real\|realisim), photorealistic(photo_real)` |
|
||||
|
||||
## Tips
|
||||
|
||||
- Keep canonical names short and meaningful—they become folder names.
|
||||
- Place the most important categories first; the first match wins.
|
||||
- Avoid duplicate canonical names within the same list; only the first instance is used.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
|
||||
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
|
||||
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
|
||||
|
||||
With these basics you can quickly adapt Priority Tags to match your library’s organization style.
|
||||
26
docs/testing/coverage_analysis.md
Normal file
26
docs/testing/coverage_analysis.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Backend Test Coverage Notes
|
||||
|
||||
## Pytest Execution
|
||||
- Command: `python -m pytest`
|
||||
- Result: All 283 collected tests passed in the current environment.
|
||||
- Coverage tooling (``pytest-cov``/``coverage``) is unavailable in the offline sandbox, so line-level metrics could not be generated. The earlier attempt to install ``pytest-cov`` failed because the package index cannot be reached from the container.
|
||||
|
||||
## High-Priority Gaps to Address
|
||||
|
||||
### 1. Standalone server bootstrapping
|
||||
* **Source:** [`standalone.py`](../../standalone.py)
|
||||
* **Why it matters:** The standalone entry point wires together the aiohttp application, static asset routes, model-route registration, and configuration validation. None of these behaviours are covered by automated tests, leaving regressions in bootstrapping logic undetected.
|
||||
* **Suggested coverage:** Add integration-style tests that instantiate `StandaloneServer`/`StandaloneLoraManager` with temporary settings and assert that routes (HTTP + websocket) are registered, configuration warnings fire for missing paths, and the mock ComfyUI shims behave as expected.
|
||||
|
||||
### 2. Model service registration factory
|
||||
* **Source:** [`py/services/model_service_factory.py`](../../py/services/model_service_factory.py)
|
||||
* **Why it matters:** The factory coordinates which model services and routes the API exposes, including error handling when unknown model types are requested. No current tests verify registration, memoization of route instances, or the logging path on failures.
|
||||
* **Suggested coverage:** Unit tests that exercise `register_model_type`, `get_route_instance`, error branches in `get_service_class`/`get_route_class`, and `setup_all_routes` when a route setup raises. Use lightweight fakes to confirm the logger is called and state is cleared via `clear_registrations`.
|
||||
|
||||
### 3. Server-side i18n helper
|
||||
* **Source:** [`py/services/server_i18n.py`](../../py/services/server_i18n.py)
|
||||
* **Why it matters:** Template rendering relies on the `ServerI18nManager` to load locale JSON, perform key lookups, and format parameters. The fallback logic (dot-notation lookup, English fallbacks, placeholder substitution) is untested, so malformed locale files or regressions in placeholder handling would slip through.
|
||||
* **Suggested coverage:** Tests that load fixture locale dictionaries, assert `set_locale` fallbacks, verify nested key resolution and placeholder substitution, and ensure missing keys return the original identifier.
|
||||
|
||||
## Next Steps
|
||||
Prioritize creating focused unit tests around these modules, then re-run pytest once coverage tooling is available to confirm the new tests close the identified gaps.
|
||||
205
locales/de.json
205
locales/de.json
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint-Name kopiert",
|
||||
"toggleBlur": "Unschärfe umschalten",
|
||||
"show": "Anzeigen",
|
||||
"openExampleImages": "Beispielbilder-Ordner öffnen"
|
||||
"openExampleImages": "Beispielbilder-Ordner öffnen",
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Nicht jugendfreie Inhalte",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "Fehler beim Aktualisieren des Favoriten-Status"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert"
|
||||
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert",
|
||||
"missingPath": "Modellpfad für diese Karte konnte nicht ermittelt werden"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Fehler beim Überprüfen der Beispielbilder",
|
||||
"missingHash": "Fehlende Modell-Hash-Informationen.",
|
||||
"noRemoteImagesAvailable": "Keine Remote-Beispielbilder für dieses Modell auf Civitai verfügbar"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update verfügbar"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "Bitte legen Sie einen Speicherort fest, bevor Sie Beispielbilder herunterladen.",
|
||||
"unavailable": "Beispielbild-Downloads sind noch nicht verfügbar. Versuchen Sie es erneut, nachdem die Seite vollständig geladen ist."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Auf Updates prüfen",
|
||||
"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}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Beispielbild-Ordner bereinigen",
|
||||
"success": "{count} Ordner wurden in den Papierkorb verschoben",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Updates prüfen",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"support": "Unterstützung"
|
||||
}
|
||||
},
|
||||
@@ -187,11 +206,18 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
|
||||
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Einstellungsordner öffnen",
|
||||
"tooltip": "Den Ordner mit der settings.json öffnen",
|
||||
"success": "Einstellungsordner geöffnet",
|
||||
"failed": "Einstellungsordner konnte nicht geöffnet werden"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Inhaltsfilterung",
|
||||
"videoSettings": "Video-Einstellungen",
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"folderSettings": "Ordner-Einstellungen",
|
||||
"priorityTags": "Prioritäts-Tags",
|
||||
"downloadPathTemplates": "Download-Pfad-Vorlagen",
|
||||
"exampleImages": "Beispielbilder",
|
||||
"misc": "Verschiedenes",
|
||||
@@ -217,23 +243,38 @@
|
||||
},
|
||||
"displayDensityHelp": "Wählen Sie, wie viele Karten pro Zeile angezeigt werden sollen:",
|
||||
"displayDensityDetails": {
|
||||
"default": "Standard: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Mittel: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Kompakt: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
|
||||
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
|
||||
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
||||
"cardInfoDisplay": "Karten-Info-Anzeige",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Immer sichtbar",
|
||||
"hover": "Bei Hover anzeigen"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Immer sichtbar: Kopf- und Fußzeilen sind immer sichtbar",
|
||||
"hover": "Bei Hover anzeigen: Kopf- und Fußzeilen erscheinen nur beim Darüberfahren mit der Maus"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
|
||||
|
||||
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Beispielbilder öffnen",
|
||||
"replacePreview": "Vorschau ersetzen"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Wähle aus, was die Schaltfläche unten rechts auf der Karte ausführt",
|
||||
"modelNameDisplay": "Anzeige des Modellnamens",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Modellname",
|
||||
"fileName": "Dateiname"
|
||||
},
|
||||
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Aktive Bibliothek",
|
||||
"activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu.",
|
||||
"loadingLibraries": "Bibliotheken werden geladen...",
|
||||
"noLibraries": "Keine Bibliotheken konfiguriert",
|
||||
"defaultLoraRoot": "Standard-LoRA-Stammordner",
|
||||
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
|
||||
@@ -242,6 +283,26 @@
|
||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"noDefault": "Kein Standard"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Prioritäts-Tags",
|
||||
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Prioritäts-Tags-Hilfe öffnen",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Prioritäts-Tags aktualisiert.",
|
||||
"saveError": "Prioritäts-Tags konnten nicht aktualisiert werden.",
|
||||
"loadingSuggestions": "Lade Vorschläge...",
|
||||
"validation": {
|
||||
"missingClosingParen": "Eintrag {index} fehlt eine schließende Klammer.",
|
||||
"missingCanonical": "Eintrag {index} muss einen kanonischen Tag-Namen enthalten.",
|
||||
"duplicateCanonical": "Der kanonische Tag \"{tag}\" kommt mehrfach vor.",
|
||||
"unknown": "Ungültige Prioritäts-Tag-Konfiguration."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Download-Pfad-Vorlagen",
|
||||
"help": "Konfigurieren Sie Ordnerstrukturen für verschiedene Modelltypen beim Herunterladen von Civitai.",
|
||||
@@ -352,8 +413,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Modelliste aktualisieren",
|
||||
"quick": "Schnelle Aktualisierung (inkrementell)",
|
||||
"full": "Vollständiger Neuaufbau (komplett)"
|
||||
"quick": "Änderungen synchronisieren",
|
||||
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
|
||||
"full": "Cache neu aufbauen",
|
||||
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden – nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Metadaten von Civitai abrufen",
|
||||
@@ -374,6 +437,13 @@
|
||||
"favorites": {
|
||||
"title": "Nur Favoriten anzeigen",
|
||||
"action": "Favoriten"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Nur Modelle mit verfügbaren Updates anzeigen",
|
||||
"action": "Updates",
|
||||
"menuLabel": "Weitere Update-Optionen anzeigen",
|
||||
"check": "Updates prüfen",
|
||||
"checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -382,8 +452,10 @@
|
||||
"viewSelected": "Auswahl anzeigen",
|
||||
"addTags": "Allen Tags hinzufügen",
|
||||
"setBaseModel": "Basis-Modell für alle festlegen",
|
||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
"autoOrganize": "Automatisch organisieren",
|
||||
"deleteAll": "Alle Modelle löschen",
|
||||
@@ -517,13 +589,19 @@
|
||||
"title": "Embedding-Modelle"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Modell-Stammverzeichnis",
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
"pinSidebar": "Sidebar anheften",
|
||||
"unpinSidebar": "Sidebar lösen",
|
||||
"switchToListView": "Zur Listenansicht wechseln",
|
||||
"switchToTreeView": "Zur Baumansicht wechseln",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar"
|
||||
"recursiveOn": "Unterordner durchsuchen",
|
||||
"recursiveOff": "Nur aktuellen Ordner durchsuchen",
|
||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistiken",
|
||||
@@ -598,6 +676,14 @@
|
||||
"downloadedPreview": "Vorschaubild heruntergeladen",
|
||||
"downloadingFile": "{type}-Datei wird heruntergeladen",
|
||||
"finalizing": "Download wird abgeschlossen..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Aktuelle Datei:",
|
||||
"downloading": "Wird heruntergeladen: {name}",
|
||||
"transferred": "Heruntergeladen: {downloaded} / {total}",
|
||||
"transferredSimple": "Heruntergeladen: {downloaded}",
|
||||
"transferredUnknown": "Heruntergeladen: --",
|
||||
"speed": "Geschwindigkeit: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +692,7 @@
|
||||
"contentRating": {
|
||||
"title": "Inhaltsbewertung festlegen",
|
||||
"current": "Aktuell",
|
||||
"multiple": "Mehrere Werte",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +731,12 @@
|
||||
"countMessage": "Modelle werden dauerhaft gelöscht.",
|
||||
"action": "Alle löschen"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Alle {typePlural} auf Updates prüfen?",
|
||||
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
|
||||
"tip": "Du möchtest in Etappen prüfen? Wechsle in den Sammelmodus, wähle die benötigten Modelle aus und nutze anschließend \"Auswahl auf Updates prüfen\".",
|
||||
"action": "Alles prüfen"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Tags zu mehreren Modellen hinzufügen",
|
||||
"description": "Tags hinzufügen zu",
|
||||
@@ -780,13 +873,55 @@
|
||||
"tabs": {
|
||||
"examples": "Beispiele",
|
||||
"description": "Modellbeschreibung",
|
||||
"recipes": "Rezepte"
|
||||
"recipes": "Rezepte",
|
||||
"versions": "Versionen"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Beispielbilder werden geladen...",
|
||||
"description": "Modellbeschreibung wird geladen...",
|
||||
"recipes": "Rezepte werden geladen...",
|
||||
"examples": "Beispiele werden geladen..."
|
||||
"examples": "Beispiele werden geladen...",
|
||||
"versions": "Versionen werden geladen..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Modellversionen",
|
||||
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
||||
"media": {
|
||||
"placeholder": "Keine Vorschau"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Unbenannte Version",
|
||||
"noDetails": "Keine zusätzlichen Details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Aktuelle Version",
|
||||
"inLibrary": "In der Bibliothek",
|
||||
"newer": "Neuere Version",
|
||||
"ignored": "Ignoriert"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Herunterladen",
|
||||
"delete": "Löschen",
|
||||
"ignore": "Ignorieren",
|
||||
"unignore": "Ignorierung aufheben",
|
||||
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
|
||||
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
|
||||
"viewLocalVersions": "Alle lokalen Versionen anzeigen",
|
||||
"viewLocalTooltip": "Demnächst verfügbar"
|
||||
},
|
||||
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
|
||||
"error": "Versionen konnten nicht geladen werden.",
|
||||
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
|
||||
"confirm": {
|
||||
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Aktualisierungen für dieses Modell werden ignoriert",
|
||||
"modelResumed": "Aktualisierungen für dieses Modell werden wieder geprüft",
|
||||
"versionIgnored": "Aktualisierungen für diese Version werden ignoriert",
|
||||
"versionUnignored": "Version wurde wieder aktiviert",
|
||||
"versionDeleted": "Version gelöscht"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1028,9 @@
|
||||
"loraFailedToSend": "Fehler beim Senden der LoRA an den Workflow",
|
||||
"recipeAdded": "Rezept zum Workflow hinzugefügt",
|
||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow"
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Rezept",
|
||||
@@ -938,6 +1075,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Nach Updates suchen",
|
||||
"notificationsTitle": "Benachrichtigungszentrum",
|
||||
"tabs": {
|
||||
"updates": "Aktualisierungen",
|
||||
"messages": "Mitteilungen"
|
||||
},
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
|
||||
"currentVersion": "Aktuelle Version",
|
||||
@@ -969,6 +1111,13 @@
|
||||
"nightly": {
|
||||
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
|
||||
"enable": "Nightly Updates aktivieren"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Neueste Mitteilungen",
|
||||
"empty": "Keine aktuellen Banner verfügbar.",
|
||||
"shown": "{time} angezeigt",
|
||||
"dismissed": "{time} geschlossen",
|
||||
"active": "Aktiv"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1233,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert",
|
||||
"bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen",
|
||||
"bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen",
|
||||
"bulkContentRatingUpdating": "Inhaltsbewertung wird für {count} Modell(e) aktualisiert...",
|
||||
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
|
||||
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
|
||||
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
|
||||
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
|
||||
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
|
||||
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
|
||||
"bulkUpdatesMissing": "Ausgewählte {type}-Modelle sind nicht mit Civitai-Updates verknüpft",
|
||||
"bulkUpdatesPartialMissing": "{missing} ausgewählte {type}-Modelle ohne Civitai-Verknüpfung übersprungen",
|
||||
"bulkUpdatesFailed": "Updates für ausgewählte {type}-Modelle konnten nicht geprüft werden: {message}",
|
||||
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
|
||||
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
|
||||
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
|
||||
@@ -1118,6 +1277,8 @@
|
||||
"compactModeToggled": "Kompakt-Modus {state}",
|
||||
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
|
||||
"displayDensitySet": "Anzeige-Dichte auf {density} gesetzt",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "Fehler beim Ändern der Sprache: {message}",
|
||||
"cacheCleared": "Cache-Dateien wurden erfolgreich gelöscht. Cache wird bei der nächsten Aktion neu aufgebaut.",
|
||||
"cacheClearFailed": "Fehler beim Löschen des Caches: {error}",
|
||||
@@ -1191,6 +1352,8 @@
|
||||
"pauseFailed": "Fehler beim Pausieren des Downloads: {error}",
|
||||
"downloadResumed": "Download fortgesetzt",
|
||||
"resumeFailed": "Fehler beim Fortsetzen des Downloads: {error}",
|
||||
"downloadStopped": "Download abgebrochen",
|
||||
"stopFailed": "Download konnte nicht abgebrochen werden: {error}",
|
||||
"deleted": "Beispielbild gelöscht",
|
||||
"deleteFailed": "Fehler beim Löschen des Beispielbilds",
|
||||
"setPreviewFailed": "Fehler beim Setzen des Vorschaubilds"
|
||||
@@ -1237,6 +1400,12 @@
|
||||
"refreshNow": "Jetzt aktualisieren",
|
||||
"refreshingIn": "Aktualisierung in",
|
||||
"seconds": "Sekunden"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
218
locales/en.json
218
locales/en.json
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint name copied",
|
||||
"toggleBlur": "Toggle blur",
|
||||
"show": "Show",
|
||||
"openExampleImages": "Open Example Images Folder"
|
||||
"openExampleImages": "Open Example Images Folder",
|
||||
"replacePreview": "Replace Preview",
|
||||
"copyCheckpointName": "Copy checkpoint name",
|
||||
"copyEmbeddingName": "Copy embedding name",
|
||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Mature Content",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "Failed to update favorite status"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented"
|
||||
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented",
|
||||
"missingPath": "Unable to determine model path for this card"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Error checking for example images",
|
||||
"missingHash": "Missing model hash information.",
|
||||
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
"updateAvailable": "Update available"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "Set a download location before downloading example images.",
|
||||
"unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Check for updates",
|
||||
"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}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Clean up example image folders",
|
||||
"success": "Moved {count} folder(s) to the deleted folder",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Check Updates",
|
||||
"notifications": "Notifications",
|
||||
"support": "Support"
|
||||
}
|
||||
},
|
||||
@@ -187,11 +206,18 @@
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Open settings folder",
|
||||
"tooltip": "Open the folder containing settings.json",
|
||||
"success": "Opened settings.json folder",
|
||||
"failed": "Failed to open settings.json folder"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Content Filtering",
|
||||
"videoSettings": "Video Settings",
|
||||
"layoutSettings": "Layout Settings",
|
||||
"folderSettings": "Folder Settings",
|
||||
"priorityTags": "Priority Tags",
|
||||
"downloadPathTemplates": "Download Path Templates",
|
||||
"exampleImages": "Example Images",
|
||||
"misc": "Misc.",
|
||||
@@ -212,28 +238,42 @@
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
"medium": "Medium",
|
||||
"medium": "Medium",
|
||||
"compact": "Compact"
|
||||
},
|
||||
"displayDensityHelp": "Choose how many cards to display per row:",
|
||||
"displayDensityDetails": {
|
||||
"default": "Default: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Medium: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Compact: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
||||
"showFolderSidebar": "Show Folder Sidebar",
|
||||
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
||||
"cardInfoDisplay": "Card Info Display",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Always Visible",
|
||||
"hover": "Reveal on Hover"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Always Visible: Headers and footers are always visible",
|
||||
"hover": "Reveal on Hover: Headers and footers only appear when hovering over a card"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
|
||||
"modelCardFooterAction": "Model Card Button Action",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Open Example Images",
|
||||
"replacePreview": "Replace Preview"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Choose what the bottom-right card button does",
|
||||
"modelNameDisplay": "Model Name Display",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Model Name",
|
||||
"fileName": "File Name"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choose what to display in the model card footer"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Active Library",
|
||||
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.",
|
||||
"loadingLibraries": "Loading libraries...",
|
||||
"noLibraries": "No libraries configured",
|
||||
"defaultLoraRoot": "Default LoRA Root",
|
||||
"defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves",
|
||||
"defaultCheckpointRoot": "Default Checkpoint Root",
|
||||
@@ -242,6 +282,26 @@
|
||||
"defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves",
|
||||
"noDefault": "No Default"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Priority Tags",
|
||||
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Open priority tags help",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Priority tags updated.",
|
||||
"saveError": "Failed to update priority tags.",
|
||||
"loadingSuggestions": "Loading suggestions...",
|
||||
"validation": {
|
||||
"missingClosingParen": "Entry {index} is missing a closing parenthesis.",
|
||||
"missingCanonical": "Entry {index} must include a canonical tag name.",
|
||||
"duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.",
|
||||
"unknown": "Invalid priority tag configuration."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Download Path Templates",
|
||||
"help": "Configure folder structures for different model types when downloading from Civitai.",
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Refresh model list",
|
||||
"quick": "Quick Refresh (incremental)",
|
||||
"full": "Full Rebuild (complete)"
|
||||
"quick": "Sync Changes",
|
||||
"quickTooltip": "Scan for new or missing model files so the list stays current.",
|
||||
"full": "Rebuild Cache",
|
||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Fetch metadata from Civitai",
|
||||
@@ -374,19 +436,28 @@
|
||||
"favorites": {
|
||||
"title": "Show Favorites Only",
|
||||
"action": "Favorites"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Show models with updates available",
|
||||
"action": "Updates",
|
||||
"menuLabel": "Show update options",
|
||||
"check": "Check updates",
|
||||
"checkTooltip": "Checking updates may take a while."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
"selected": "{count} selected",
|
||||
"selectedSuffix": "selected",
|
||||
"viewSelected": "View Selected",
|
||||
"addTags": "Add Tags to All",
|
||||
"setBaseModel": "Set Base Model for All",
|
||||
"copyAll": "Copy All Syntax",
|
||||
"refreshAll": "Refresh All Metadata",
|
||||
"moveAll": "Move All to Folder",
|
||||
"addTags": "Add Tags to Selected",
|
||||
"setBaseModel": "Set Base Model for Selected",
|
||||
"setContentRating": "Set Content Rating for Selected",
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
"autoOrganize": "Auto-Organize Selected",
|
||||
"deleteAll": "Delete All Models",
|
||||
"deleteAll": "Delete Selected Models",
|
||||
"clear": "Clear Selection",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "Initializing auto-organize...",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Embedding Models"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Model Root",
|
||||
"modelRoot": "Root",
|
||||
"collapseAll": "Collapse All Folders",
|
||||
"pinSidebar": "Pin Sidebar",
|
||||
"unpinSidebar": "Unpin Sidebar",
|
||||
"switchToListView": "Switch to List View",
|
||||
"switchToTreeView": "Switch to Tree View",
|
||||
"collapseAllDisabled": "Not available in list view"
|
||||
"recursiveOn": "Search subfolders",
|
||||
"recursiveOff": "Search current folder only",
|
||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Unable to determine destination path for move."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistics",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "Downloaded preview image",
|
||||
"downloadingFile": "Downloading {type} file",
|
||||
"finalizing": "Finalizing download..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Current file:",
|
||||
"downloading": "Downloading: {name}",
|
||||
"transferred": "Transferred: {downloaded} / {total}",
|
||||
"transferredSimple": "Transferred: {downloaded}",
|
||||
"transferredUnknown": "Transferred: --",
|
||||
"speed": "Speed: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "Set Content Rating",
|
||||
"current": "Current",
|
||||
"multiple": "Multiple values",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "models will be permanently deleted.",
|
||||
"action": "Delete All"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Check updates for all {typePlural}?",
|
||||
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
|
||||
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
|
||||
"action": "Check All"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Add Tags to Multiple Models",
|
||||
"description": "Add tags to",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Examples",
|
||||
"description": "Model Description",
|
||||
"recipes": "Recipes"
|
||||
"recipes": "Recipes",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Loading example images...",
|
||||
"description": "Loading model description...",
|
||||
"recipes": "Loading recipes...",
|
||||
"examples": "Loading examples..."
|
||||
"examples": "Loading examples...",
|
||||
"versions": "Loading versions..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
"copy": "Track and manage every version of this model in one place.",
|
||||
"media": {
|
||||
"placeholder": "No preview"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Untitled Version",
|
||||
"noDetails": "No additional details"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Current Version",
|
||||
"inLibrary": "In Library",
|
||||
"newer": "Newer Version",
|
||||
"ignored": "Ignored"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"delete": "Delete",
|
||||
"ignore": "Ignore",
|
||||
"unignore": "Unignore",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
"viewLocalVersions": "View all local versions",
|
||||
"viewLocalTooltip": "Coming soon"
|
||||
},
|
||||
"empty": "No version history available for this model yet.",
|
||||
"error": "Failed to load versions.",
|
||||
"missingModelId": "This model is missing a Civitai model id.",
|
||||
"confirm": {
|
||||
"delete": "Delete this version from your library?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Updates ignored for this model",
|
||||
"modelResumed": "Update tracking resumed",
|
||||
"versionIgnored": "Updates ignored for this version",
|
||||
"versionUnignored": "Version re-enabled",
|
||||
"versionDeleted": "Version deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "Failed to send LoRA to workflow",
|
||||
"recipeAdded": "Recipe appended to workflow",
|
||||
"recipeReplaced": "Recipe replaced in workflow",
|
||||
"recipeFailedToSend": "Failed to send recipe to workflow"
|
||||
"recipeFailedToSend": "Failed to send recipe to workflow",
|
||||
"noMatchingNodes": "No compatible nodes available in the current workflow",
|
||||
"noTargetNodeSelected": "No target node selected"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Check for Updates",
|
||||
"notificationsTitle": "Notifications",
|
||||
"tabs": {
|
||||
"updates": "Updates",
|
||||
"messages": "Messages"
|
||||
},
|
||||
"updateAvailable": "Update Available",
|
||||
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
|
||||
"currentVersion": "Current Version",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
|
||||
"enable": "Enable Nightly Updates"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Recent messages",
|
||||
"empty": "No recent banners yet.",
|
||||
"shown": "Shown {time}",
|
||||
"dismissed": "Dismissed {time}",
|
||||
"active": "Active"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
|
||||
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
|
||||
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
|
||||
"bulkContentRatingUpdating": "Updating content rating for {count} model(s)...",
|
||||
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
|
||||
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
|
||||
"bulkContentRatingFailed": "Failed to update content rating for selected models",
|
||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||
"bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates",
|
||||
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links",
|
||||
"bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}",
|
||||
"invalidCharactersRemoved": "Invalid characters removed from filename",
|
||||
"filenameCannotBeEmpty": "File name cannot be empty",
|
||||
"renameFailed": "Failed to rename file: {message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "Compact Mode {state}",
|
||||
"settingSaveFailed": "Failed to save setting: {message}",
|
||||
"displayDensitySet": "Display Density set to {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "Failed to change language: {message}",
|
||||
"cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.",
|
||||
"cacheClearFailed": "Failed to clear cache: {error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "Failed to pause download: {error}",
|
||||
"downloadResumed": "Download resumed",
|
||||
"resumeFailed": "Failed to resume download: {error}",
|
||||
"downloadStopped": "Download cancelled",
|
||||
"stopFailed": "Failed to cancel download: {error}",
|
||||
"deleted": "Example image deleted",
|
||||
"deleteFailed": "Failed to delete example image",
|
||||
"setPreviewFailed": "Failed to set preview image"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "Refresh Now",
|
||||
"refreshingIn": "Refreshing in",
|
||||
"seconds": "seconds"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
204
locales/es.json
204
locales/es.json
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "Nombre del checkpoint copiado",
|
||||
"toggleBlur": "Alternar difuminado",
|
||||
"show": "Mostrar",
|
||||
"openExampleImages": "Abrir carpeta de imágenes de ejemplo"
|
||||
"openExampleImages": "Abrir carpeta de imágenes de ejemplo",
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Contenido para adultos",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "Error al actualizar estado de favoritos"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar"
|
||||
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar",
|
||||
"missingPath": "No se puede determinar la ruta del modelo para esta tarjeta"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Error al verificar imágenes de ejemplo",
|
||||
"missingHash": "Falta información del hash del modelo.",
|
||||
"noRemoteImagesAvailable": "No hay imágenes de ejemplo remotas disponibles para este modelo en Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Actualización",
|
||||
"updateAvailable": "Actualización disponible"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "Establece una ubicación de descarga antes de descargar imágenes de ejemplo.",
|
||||
"unavailable": "Las descargas de imágenes de ejemplo aún no están disponibles. Intenta de nuevo después de que la página termine de cargar."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Buscar actualizaciones",
|
||||
"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}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Limpiar carpetas de imágenes de ejemplo",
|
||||
"success": "Se movieron {count} carpeta(s) a la carpeta de eliminados",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Comprobar actualizaciones",
|
||||
"notifications": "Notificaciones",
|
||||
"support": "Soporte"
|
||||
}
|
||||
},
|
||||
@@ -187,11 +206,18 @@
|
||||
"civitaiApiKey": "Clave API de Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
|
||||
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Abrir carpeta de ajustes",
|
||||
"tooltip": "Abrir la carpeta que contiene settings.json",
|
||||
"success": "Carpeta de settings.json abierta",
|
||||
"failed": "No se pudo abrir la carpeta de settings.json"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Filtrado de contenido",
|
||||
"videoSettings": "Configuración de video",
|
||||
"layoutSettings": "Configuración de diseño",
|
||||
"folderSettings": "Configuración de carpetas",
|
||||
"priorityTags": "Etiquetas prioritarias",
|
||||
"downloadPathTemplates": "Plantillas de rutas de descarga",
|
||||
"exampleImages": "Imágenes de ejemplo",
|
||||
"misc": "Varios",
|
||||
@@ -217,23 +243,37 @@
|
||||
},
|
||||
"displayDensityHelp": "Elige cuántas tarjetas mostrar por fila:",
|
||||
"displayDensityDetails": {
|
||||
"default": "Predeterminado: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Medio: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Compacto: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
||||
"showFolderSidebar": "Mostrar barra lateral de carpetas",
|
||||
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
|
||||
"cardInfoDisplay": "Visualización de información de tarjeta",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Siempre visible",
|
||||
"hover": "Mostrar al pasar el ratón"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Siempre visible: Los encabezados y pies de página siempre son visibles",
|
||||
"hover": "Mostrar al pasar el ratón: Los encabezados y pies de página solo aparecen al pasar el ratón sobre una tarjeta"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
|
||||
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Abrir imágenes de ejemplo",
|
||||
"replacePreview": "Reemplazar vista previa"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Elige qué hace el botón en la esquina inferior derecha de la tarjeta",
|
||||
"modelNameDisplay": "Visualización del nombre del modelo",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Nombre del modelo",
|
||||
"fileName": "Nombre del archivo"
|
||||
},
|
||||
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Biblioteca activa",
|
||||
"activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página.",
|
||||
"loadingLibraries": "Cargando bibliotecas...",
|
||||
"noLibraries": "No hay bibliotecas configuradas",
|
||||
"defaultLoraRoot": "Raíz predeterminada de LoRA",
|
||||
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
|
||||
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
|
||||
@@ -242,6 +282,26 @@
|
||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||
"noDefault": "Sin predeterminado"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Etiquetas prioritarias",
|
||||
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Abrir ayuda de etiquetas prioritarias",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Etiquetas prioritarias actualizadas.",
|
||||
"saveError": "Error al actualizar las etiquetas prioritarias.",
|
||||
"loadingSuggestions": "Cargando sugerencias...",
|
||||
"validation": {
|
||||
"missingClosingParen": "A la entrada {index} le falta un paréntesis de cierre.",
|
||||
"missingCanonical": "La entrada {index} debe incluir un nombre de etiqueta canónica.",
|
||||
"duplicateCanonical": "La etiqueta canónica \"{tag}\" aparece más de una vez.",
|
||||
"unknown": "Configuración de etiquetas prioritarias no válida."
|
||||
}
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Plantillas de rutas de descarga",
|
||||
"help": "Configurar estructuras de carpetas para diferentes tipos de modelos al descargar de Civitai.",
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualizar lista de modelos",
|
||||
"quick": "Actualización rápida (incremental)",
|
||||
"full": "Reconstrucción completa"
|
||||
"quick": "Sincronizar cambios",
|
||||
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
|
||||
"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."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Obtener metadatos de Civitai",
|
||||
@@ -374,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "Mostrar solo favoritos",
|
||||
"action": "Favoritos"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Mostrar solo modelos con actualizaciones disponibles",
|
||||
"action": "Actualizaciones",
|
||||
"menuLabel": "Mostrar opciones de actualización",
|
||||
"check": "Buscar actualizaciones",
|
||||
"checkTooltip": "Comprobar actualizaciones puede tardar."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -382,8 +451,10 @@
|
||||
"viewSelected": "Ver seleccionados",
|
||||
"addTags": "Añadir etiquetas a todos",
|
||||
"setBaseModel": "Establecer modelo base para todos",
|
||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
"autoOrganize": "Auto-organizar seleccionados",
|
||||
"deleteAll": "Eliminar todos los modelos",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Modelos embedding"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz del modelo",
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
"pinSidebar": "Fijar barra lateral",
|
||||
"unpinSidebar": "Desfijar barra lateral",
|
||||
"switchToListView": "Cambiar a vista de lista",
|
||||
"switchToTreeView": "Cambiar a vista de árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista"
|
||||
"recursiveOn": "Buscar en subcarpetas",
|
||||
"recursiveOff": "Buscar solo en la carpeta actual",
|
||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Estadísticas",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "Imagen de vista previa descargada",
|
||||
"downloadingFile": "Descargando archivo de {type}",
|
||||
"finalizing": "Finalizando descarga..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Archivo actual:",
|
||||
"downloading": "Descargando: {name}",
|
||||
"transferred": "Descargado: {downloaded} / {total}",
|
||||
"transferredSimple": "Descargado: {downloaded}",
|
||||
"transferredUnknown": "Descargado: --",
|
||||
"speed": "Velocidad: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "Establecer clasificación de contenido",
|
||||
"current": "Actual",
|
||||
"multiple": "Valores múltiples",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "modelos serán eliminados permanentemente.",
|
||||
"action": "Eliminar todo"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
|
||||
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
|
||||
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
|
||||
"action": "Comprobar todo"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Añadir etiquetas a múltiples modelos",
|
||||
"description": "Añadir etiquetas a",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Ejemplos",
|
||||
"description": "Descripción del modelo",
|
||||
"recipes": "Recetas"
|
||||
"recipes": "Recetas",
|
||||
"versions": "Versiones"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Cargando imágenes de ejemplo...",
|
||||
"description": "Cargando descripción del modelo...",
|
||||
"recipes": "Cargando recetas...",
|
||||
"examples": "Cargando ejemplos..."
|
||||
"examples": "Cargando ejemplos...",
|
||||
"versions": "Cargando versiones..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versiones del modelo",
|
||||
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
||||
"media": {
|
||||
"placeholder": "Sin vista previa"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Versión sin nombre",
|
||||
"noDetails": "Sin detalles adicionales"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Versión actual",
|
||||
"inLibrary": "En la biblioteca",
|
||||
"newer": "Versión más reciente",
|
||||
"ignored": "Ignorada"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Descargar",
|
||||
"delete": "Eliminar",
|
||||
"ignore": "Ignorar",
|
||||
"unignore": "Dejar de ignorar",
|
||||
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
|
||||
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
|
||||
"viewLocalVersions": "Ver todas las versiones locales",
|
||||
"viewLocalTooltip": "Disponible pronto"
|
||||
},
|
||||
"empty": "Aún no hay historial de versiones para este modelo.",
|
||||
"error": "No se pudieron cargar las versiones.",
|
||||
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
|
||||
"confirm": {
|
||||
"delete": "¿Eliminar esta versión de tu biblioteca?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Se ignoran las actualizaciones de este modelo",
|
||||
"modelResumed": "Seguimiento de actualizaciones reanudado",
|
||||
"versionIgnored": "Se ignoran las actualizaciones de esta versión",
|
||||
"versionUnignored": "Versión habilitada nuevamente",
|
||||
"versionDeleted": "Versión eliminada"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "Error al enviar LoRA al flujo de trabajo",
|
||||
"recipeAdded": "Receta añadida al flujo de trabajo",
|
||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo"
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Receta",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Comprobar actualizaciones",
|
||||
"notificationsTitle": "Centro de notificaciones",
|
||||
"tabs": {
|
||||
"updates": "Actualizaciones",
|
||||
"messages": "Mensajes"
|
||||
},
|
||||
"updateAvailable": "Actualización disponible",
|
||||
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
|
||||
"currentVersion": "Versión actual",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
|
||||
"enable": "Habilitar actualizaciones nocturnas"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Notificaciones recientes",
|
||||
"empty": "No hay banners recientes.",
|
||||
"shown": "Mostrado {time}",
|
||||
"dismissed": "Descartado {time}",
|
||||
"active": "Activo"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)",
|
||||
"bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)",
|
||||
"bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados",
|
||||
"bulkContentRatingUpdating": "Actualizando la clasificación de contenido para {count} modelo(s)...",
|
||||
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
|
||||
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
|
||||
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
|
||||
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
|
||||
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
|
||||
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
|
||||
"bulkUpdatesMissing": "Los {type} seleccionados no están vinculados a actualizaciones de Civitai",
|
||||
"bulkUpdatesPartialMissing": "Se omitieron {missing} {type} seleccionados sin enlace de Civitai",
|
||||
"bulkUpdatesFailed": "Error al comprobar actualizaciones para los {type} seleccionados: {message}",
|
||||
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
|
||||
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
|
||||
"renameFailed": "Error al renombrar archivo: {message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "Modo compacto {state}",
|
||||
"settingSaveFailed": "Error al guardar configuración: {message}",
|
||||
"displayDensitySet": "Densidad de visualización establecida a {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "Error al cambiar idioma: {message}",
|
||||
"cacheCleared": "Archivos de caché limpiados exitosamente. La caché se reconstruirá en la próxima acción.",
|
||||
"cacheClearFailed": "Error al limpiar caché: {error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "Error al pausar descarga: {error}",
|
||||
"downloadResumed": "Descarga reanudada",
|
||||
"resumeFailed": "Error al reanudar descarga: {error}",
|
||||
"downloadStopped": "Descarga cancelada",
|
||||
"stopFailed": "Error al cancelar descarga: {error}",
|
||||
"deleted": "Imagen de ejemplo eliminada",
|
||||
"deleteFailed": "Error al eliminar imagen de ejemplo",
|
||||
"setPreviewFailed": "Error al establecer imagen de vista previa"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "Actualizar ahora",
|
||||
"refreshingIn": "Actualizando en",
|
||||
"seconds": "segundos"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
locales/fr.json
208
locales/fr.json
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Octets",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "Nom du checkpoint copié",
|
||||
"toggleBlur": "Basculer le flou",
|
||||
"show": "Afficher",
|
||||
"openExampleImages": "Ouvrir le dossier d'images d'exemple"
|
||||
"openExampleImages": "Ouvrir le dossier d'images d'exemple",
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Contenu pour adultes",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "Échec de la mise à jour du statut des favoris"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter"
|
||||
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter",
|
||||
"missingPath": "Impossible de déterminer le chemin du modèle pour cette carte"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Erreur lors de la vérification des images d'exemple",
|
||||
"missingHash": "Informations de hachage du modèle manquantes.",
|
||||
"noRemoteImagesAvailable": "Aucune image d'exemple distante disponible pour ce modèle sur Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Mise à jour",
|
||||
"updateAvailable": "Mise à jour disponible"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,8 +139,15 @@
|
||||
"missingPath": "Définissez un emplacement de téléchargement avant de télécharger les images d'exemple.",
|
||||
"unavailable": "Le téléchargement des images d'exemple n'est pas encore disponible. Réessayez après le chargement complet de la page."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Vérifier les mises à jour",
|
||||
"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}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Nettoyer les dossiers d'images d'exemple",
|
||||
"label": "Supprimer les dossiers d'exemples orphelins",
|
||||
"success": "{count} dossier(s) déplacé(s) vers le dossier supprimé",
|
||||
"none": "Aucun dossier d'images d'exemple à nettoyer",
|
||||
"partial": "Nettoyage terminé avec {failures} dossier(s) ignoré(s)",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"notifications": "Notifications",
|
||||
"support": "Support"
|
||||
}
|
||||
},
|
||||
@@ -187,6 +206,12 @@
|
||||
"civitaiApiKey": "Clé API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
|
||||
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Ouvrir le dossier des paramètres",
|
||||
"tooltip": "Ouvrir le dossier contenant settings.json",
|
||||
"success": "Dossier settings.json ouvert",
|
||||
"failed": "Impossible d'ouvrir le dossier settings.json"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Filtrage du contenu",
|
||||
"videoSettings": "Paramètres vidéo",
|
||||
@@ -196,7 +221,8 @@
|
||||
"exampleImages": "Images d'exemple",
|
||||
"misc": "Divers",
|
||||
"metadataArchive": "Base de données d'archive des métadonnées",
|
||||
"proxySettings": "Paramètres du proxy"
|
||||
"proxySettings": "Paramètres du proxy",
|
||||
"priorityTags": "Étiquettes prioritaires"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "Flouter le contenu NSFW",
|
||||
@@ -217,23 +243,37 @@
|
||||
},
|
||||
"displayDensityHelp": "Choisissez combien de cartes afficher par ligne :",
|
||||
"displayDensityDetails": {
|
||||
"default": "Par défaut : 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Moyen : 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Compact : 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
|
||||
"showFolderSidebar": "Afficher la barre latérale des dossiers",
|
||||
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
|
||||
"cardInfoDisplay": "Affichage des informations de carte",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Toujours visible",
|
||||
"hover": "Révéler au survol"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action :",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Toujours visible : Les en-têtes et pieds de page sont toujours visibles",
|
||||
"hover": "Révéler au survol : Les en-têtes et pieds de page n'apparaissent qu'au survol d'une carte"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
|
||||
"modelCardFooterAction": "Action du bouton de carte de modèle",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Ouvrir les images d'exemple",
|
||||
"replacePreview": "Remplacer l'aperçu"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Choisissez ce que fait le bouton en bas à droite de la carte",
|
||||
"modelNameDisplay": "Affichage du nom du modèle",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Nom du modèle",
|
||||
"fileName": "Nom du fichier"
|
||||
},
|
||||
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Bibliothèque active",
|
||||
"activeLibraryHelp": "Basculer entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page.",
|
||||
"loadingLibraries": "Chargement des bibliothèques...",
|
||||
"noLibraries": "Aucune bibliothèque configurée",
|
||||
"defaultLoraRoot": "Racine LoRA par défaut",
|
||||
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultCheckpointRoot": "Racine Checkpoint par défaut",
|
||||
@@ -334,6 +374,26 @@
|
||||
"proxyPassword": "Mot de passe (optionnel)",
|
||||
"proxyPasswordPlaceholder": "mot_de_passe",
|
||||
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Étiquettes prioritaires",
|
||||
"description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "Étiquettes prioritaires mises à jour.",
|
||||
"saveError": "Échec de la mise à jour des étiquettes prioritaires.",
|
||||
"loadingSuggestions": "Chargement des suggestions...",
|
||||
"validation": {
|
||||
"missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.",
|
||||
"missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.",
|
||||
"duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.",
|
||||
"unknown": "Configuration d'étiquettes prioritaires invalide."
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Actualiser la liste des modèles",
|
||||
"quick": "Actualisation rapide (incrémentale)",
|
||||
"full": "Reconstruction complète"
|
||||
"quick": "Synchroniser les changements",
|
||||
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
|
||||
"full": "Reconstruire le cache",
|
||||
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Récupérer les métadonnées depuis Civitai",
|
||||
@@ -374,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "Afficher uniquement les favoris",
|
||||
"action": "Favoris"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Afficher uniquement les modèles avec des mises à jour disponibles",
|
||||
"action": "Mises à jour",
|
||||
"menuLabel": "Afficher les options de mise à jour",
|
||||
"check": "Rechercher des mises à jour",
|
||||
"checkTooltip": "La vérification peut prendre du temps."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -382,8 +451,10 @@
|
||||
"viewSelected": "Voir la sélection",
|
||||
"addTags": "Ajouter des tags à tous",
|
||||
"setBaseModel": "Définir le modèle de base pour tous",
|
||||
"setContentRating": "Définir la classification du contenu pour tous",
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
"autoOrganize": "Auto-organiser la sélection",
|
||||
"deleteAll": "Supprimer tous les modèles",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Modèles Embedding"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine du modèle",
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
"pinSidebar": "Épingler la barre latérale",
|
||||
"unpinSidebar": "Désépingler la barre latérale",
|
||||
"switchToListView": "Passer en vue liste",
|
||||
"switchToTreeView": "Passer en vue arborescence",
|
||||
"collapseAllDisabled": "Non disponible en vue liste"
|
||||
"recursiveOn": "Rechercher dans les sous-dossiers",
|
||||
"recursiveOff": "Rechercher uniquement dans le dossier actuel",
|
||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Statistiques",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "Image d'aperçu téléchargée",
|
||||
"downloadingFile": "Téléchargement du fichier {type}",
|
||||
"finalizing": "Finalisation du téléchargement..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Fichier actuel :",
|
||||
"downloading": "Téléchargement : {name}",
|
||||
"transferred": "Téléchargé : {downloaded} / {total}",
|
||||
"transferredSimple": "Téléchargé : {downloaded}",
|
||||
"transferredUnknown": "Téléchargé : --",
|
||||
"speed": "Vitesse : {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "Définir la classification du contenu",
|
||||
"current": "Actuel",
|
||||
"multiple": "Valeurs multiples",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "modèles seront définitivement supprimés.",
|
||||
"action": "Tout supprimer"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
|
||||
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
|
||||
"tip": "Besoin de procéder par étapes ? Passez en mode lot, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
|
||||
"action": "Tout vérifier"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Ajouter des tags à plusieurs modèles",
|
||||
"description": "Ajouter des tags à",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Exemples",
|
||||
"description": "Description du modèle",
|
||||
"recipes": "Recipes"
|
||||
"recipes": "Recipes",
|
||||
"versions": "Versions"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Chargement des images d'exemple...",
|
||||
"description": "Chargement de la description du modèle...",
|
||||
"recipes": "Chargement des recipes...",
|
||||
"examples": "Chargement des exemples..."
|
||||
"examples": "Chargement des exemples...",
|
||||
"versions": "Chargement des versions..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versions du modèle",
|
||||
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
||||
"media": {
|
||||
"placeholder": "Aucune prévisualisation"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Version sans nom",
|
||||
"noDetails": "Aucun détail supplémentaire"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Version actuelle",
|
||||
"inLibrary": "Dans la bibliothèque",
|
||||
"newer": "Version plus récente",
|
||||
"ignored": "Ignorée"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Télécharger",
|
||||
"delete": "Supprimer",
|
||||
"ignore": "Ignorer",
|
||||
"unignore": "Ne plus ignorer",
|
||||
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
|
||||
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
|
||||
"viewLocalVersions": "Voir toutes les versions locales",
|
||||
"viewLocalTooltip": "Bientôt disponible"
|
||||
},
|
||||
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
|
||||
"error": "Échec du chargement des versions.",
|
||||
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
|
||||
"confirm": {
|
||||
"delete": "Supprimer cette version de votre bibliothèque ?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Les mises à jour de ce modèle sont ignorées",
|
||||
"modelResumed": "Suivi des mises à jour repris",
|
||||
"versionIgnored": "Les mises à jour de cette version sont ignorées",
|
||||
"versionUnignored": "Version réactivée",
|
||||
"versionDeleted": "Version supprimée"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "Échec de l'envoi du LoRA au workflow",
|
||||
"recipeAdded": "Recipe ajoutée au workflow",
|
||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow"
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Recipe",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Vérifier les mises à jour",
|
||||
"notificationsTitle": "Notifications",
|
||||
"tabs": {
|
||||
"updates": "Mises à jour",
|
||||
"messages": "Messages"
|
||||
},
|
||||
"updateAvailable": "Mise à jour disponible",
|
||||
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
|
||||
"currentVersion": "Version actuelle",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
|
||||
"enable": "Activer les mises à jour nightly"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Messages récents",
|
||||
"empty": "Aucune bannière récente.",
|
||||
"shown": "Affiché {time}",
|
||||
"dismissed": "Ignoré {time}",
|
||||
"active": "Actif"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)",
|
||||
"bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec",
|
||||
"bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés",
|
||||
"bulkContentRatingUpdating": "Mise à jour de la classification du contenu pour {count} modèle(s)...",
|
||||
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
|
||||
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
|
||||
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
|
||||
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
|
||||
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
|
||||
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
|
||||
"bulkUpdatesMissing": "Les {type} sélectionnés ne sont pas liés aux mises à jour Civitai",
|
||||
"bulkUpdatesPartialMissing": "{missing} {type} sélectionnés sans lien Civitai ignorés",
|
||||
"bulkUpdatesFailed": "Échec de la vérification des mises à jour pour les {type} sélectionnés : {message}",
|
||||
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
|
||||
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
|
||||
"renameFailed": "Échec du renommage du fichier : {message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "Mode compact {state}",
|
||||
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
|
||||
"displayDensitySet": "Densité d'affichage définie sur {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "Échec du changement de langue : {message}",
|
||||
"cacheCleared": "Les fichiers de cache ont été vidés avec succès. Le cache sera reconstruit à la prochaine action.",
|
||||
"cacheClearFailed": "Échec du vidage du cache : {error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "Échec de la mise en pause du téléchargement : {error}",
|
||||
"downloadResumed": "Téléchargement repris",
|
||||
"resumeFailed": "Échec de la reprise du téléchargement : {error}",
|
||||
"downloadStopped": "Téléchargement annulé",
|
||||
"stopFailed": "Échec de l'annulation du téléchargement : {error}",
|
||||
"deleted": "Image d'exemple supprimée",
|
||||
"deleteFailed": "Échec de la suppression de l'image d'exemple",
|
||||
"setPreviewFailed": "Échec de la définition de l'image d'aperçu"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "Actualiser maintenant",
|
||||
"refreshingIn": "Actualisation dans",
|
||||
"seconds": "secondes"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1410
locales/he.json
Normal file
1410
locales/he.json
Normal file
File diff suppressed because it is too large
Load Diff
208
locales/ja.json
208
locales/ja.json
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0バイト",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||
"toggleBlur": "ぼかしの切り替え",
|
||||
"show": "表示",
|
||||
"openExampleImages": "例画像フォルダを開く"
|
||||
"openExampleImages": "例画像フォルダを開く",
|
||||
"replacePreview": "プレビューを置換",
|
||||
"copyCheckpointName": "checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成人向けコンテンツ",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "お気に入り状態の更新に失敗しました"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能"
|
||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能",
|
||||
"missingPath": "このカードのモデルパスを特定できません"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "例画像の確認中にエラーが発生しました",
|
||||
"missingHash": "モデルハッシュ情報がありません。",
|
||||
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
|
||||
},
|
||||
"badges": {
|
||||
"update": "アップデート",
|
||||
"updateAvailable": "アップデートがあります"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "例画像をダウンロードする前にダウンロード場所を設定してください。",
|
||||
"unavailable": "例画像のダウンロードはまだ利用できません。ページの読み込みが完了してから再度お試しください。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "アップデートを確認",
|
||||
"loading": "{type} のアップデートを確認中…",
|
||||
"success": "{type} のアップデートが {count} 件見つかりました",
|
||||
"none": "すべての {type} は最新です",
|
||||
"error": "{type} のアップデート確認に失敗しました: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "例画像フォルダをクリーンアップ",
|
||||
"success": "{count} 個のフォルダを削除フォルダに移動しました",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "更新確認",
|
||||
"notifications": "通知",
|
||||
"support": "サポート"
|
||||
}
|
||||
},
|
||||
@@ -187,6 +206,12 @@
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "設定フォルダーを開く",
|
||||
"tooltip": "settings.json を含むフォルダーを開きます",
|
||||
"success": "settings.json フォルダーを開きました",
|
||||
"failed": "settings.json フォルダーを開けませんでした"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "コンテンツフィルタリング",
|
||||
"videoSettings": "動画設定",
|
||||
@@ -196,7 +221,8 @@
|
||||
"exampleImages": "例画像",
|
||||
"misc": "その他",
|
||||
"metadataArchive": "メタデータアーカイブデータベース",
|
||||
"proxySettings": "プロキシ設定"
|
||||
"proxySettings": "プロキシ設定",
|
||||
"priorityTags": "優先タグ"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||
@@ -217,23 +243,37 @@
|
||||
},
|
||||
"displayDensityHelp": "1行に表示するカード数を選択:",
|
||||
"displayDensityDetails": {
|
||||
"default": "デフォルト:5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "中:6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "コンパクト:7(1080p)、8(2K)、10(4K)"
|
||||
"default": "5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
||||
"showFolderSidebar": "フォルダサイドバーを表示",
|
||||
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
||||
"cardInfoDisplay": "カード情報表示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "常に表示",
|
||||
"hover": "ホバー時に表示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "常に表示:ヘッダーとフッターが常に表示されます",
|
||||
"hover": "ホバー時に表示:カードにホバーしたときのみヘッダーとフッターが表示されます"
|
||||
}
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "例画像を開く",
|
||||
"replacePreview": "プレビューを置換"
|
||||
},
|
||||
"modelCardFooterActionHelp": "カード右下のボタンが何をするかを選択します",
|
||||
"modelNameDisplay": "モデル名表示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "モデル名",
|
||||
"fileName": "ファイル名"
|
||||
},
|
||||
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "アクティブライブラリ",
|
||||
"activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。",
|
||||
"loadingLibraries": "ライブラリを読み込み中...",
|
||||
"noLibraries": "ライブラリが設定されていません",
|
||||
"defaultLoraRoot": "デフォルトLoRAルート",
|
||||
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
|
||||
"defaultCheckpointRoot": "デフォルトCheckpointルート",
|
||||
@@ -334,6 +374,26 @@
|
||||
"proxyPassword": "パスワード(任意)",
|
||||
"proxyPasswordPlaceholder": "パスワード",
|
||||
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先タグ",
|
||||
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "優先タグのヘルプを開く",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "チェックポイント",
|
||||
"embedding": "埋め込み"
|
||||
},
|
||||
"saveSuccess": "優先タグを更新しました。",
|
||||
"saveError": "優先タグの更新に失敗しました。",
|
||||
"loadingSuggestions": "候補を読み込み中...",
|
||||
"validation": {
|
||||
"missingClosingParen": "エントリ {index} に閉じ括弧がありません。",
|
||||
"missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。",
|
||||
"duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。",
|
||||
"unknown": "無効な優先タグ設定です。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "モデルリストを更新",
|
||||
"quick": "クイック更新(増分)",
|
||||
"full": "完全再構築(完全)"
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitaiからメタデータを取得",
|
||||
@@ -374,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "お気に入りのみ表示",
|
||||
"action": "お気に入り"
|
||||
},
|
||||
"updates": {
|
||||
"title": "アップデート可能なモデルのみ表示",
|
||||
"action": "アップデート",
|
||||
"menuLabel": "更新オプションを表示",
|
||||
"check": "アップデートを確認",
|
||||
"checkTooltip": "確認には時間がかかる場合があります。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -382,8 +451,10 @@
|
||||
"viewSelected": "選択中を表示",
|
||||
"addTags": "すべてにタグを追加",
|
||||
"setBaseModel": "すべてにベースモデルを設定",
|
||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
"autoOrganize": "自動整理を実行",
|
||||
"deleteAll": "すべてのモデルを削除",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Embeddingモデル"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "モデルルート",
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
"pinSidebar": "サイドバーを固定",
|
||||
"unpinSidebar": "サイドバーの固定を解除",
|
||||
"switchToListView": "リストビューに切り替え",
|
||||
"switchToTreeView": "ツリービューに切り替え",
|
||||
"collapseAllDisabled": "リストビューでは利用できません"
|
||||
"switchToTreeView": "ツリー表示に切り替え",
|
||||
"recursiveOn": "サブフォルダーを検索",
|
||||
"recursiveOff": "現在のフォルダーのみを検索",
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "統計",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "プレビュー画像をダウンロードしました",
|
||||
"downloadingFile": "{type}ファイルをダウンロード中",
|
||||
"finalizing": "ダウンロードを完了中..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "現在のファイル:",
|
||||
"downloading": "ダウンロード中: {name}",
|
||||
"transferred": "ダウンロード済み: {downloaded} / {total}",
|
||||
"transferredSimple": "ダウンロード済み: {downloaded}",
|
||||
"transferredUnknown": "ダウンロード済み: --",
|
||||
"speed": "速度: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "コンテンツレーティングを設定",
|
||||
"current": "現在",
|
||||
"multiple": "複数の値",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "モデルが完全に削除されます。",
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "すべての{type}の更新を確認しますか?",
|
||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||
"tip": "少しずつ確認したい場合はバルクモードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||
"action": "すべて確認"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "複数モデルにタグを追加",
|
||||
"description": "タグを追加するモデル:",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "例",
|
||||
"description": "モデル説明",
|
||||
"recipes": "レシピ"
|
||||
"recipes": "レシピ",
|
||||
"versions": "バージョン"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "例画像を読み込み中...",
|
||||
"description": "モデル説明を読み込み中...",
|
||||
"recipes": "レシピを読み込み中...",
|
||||
"examples": "例を読み込み中..."
|
||||
"examples": "例を読み込み中...",
|
||||
"versions": "バージョンを読み込み中..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||
"media": {
|
||||
"placeholder": "プレビューなし"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "名前のないバージョン",
|
||||
"noDetails": "追加情報なし"
|
||||
},
|
||||
"badges": {
|
||||
"current": "現在のバージョン",
|
||||
"inLibrary": "ライブラリにあります",
|
||||
"newer": "新しいバージョン",
|
||||
"ignored": "無視中"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"delete": "削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
"viewLocalVersions": "ローカルの全バージョンを表示",
|
||||
"viewLocalTooltip": "近日対応予定"
|
||||
},
|
||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||
"error": "バージョンの読み込みに失敗しました。",
|
||||
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||
"confirm": {
|
||||
"delete": "このバージョンをライブラリから削除しますか?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "このモデルの更新は無視されます",
|
||||
"modelResumed": "更新の監視を再開しました",
|
||||
"versionIgnored": "このバージョンの更新は無視されます",
|
||||
"versionUnignored": "バージョンを再度有効にしました",
|
||||
"versionDeleted": "バージョンを削除しました"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "LoRAをワークフローに送信できませんでした",
|
||||
"recipeAdded": "レシピがワークフローに追加されました",
|
||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした"
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "レシピ",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "更新確認",
|
||||
"notificationsTitle": "通知センター",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "メッセージ"
|
||||
},
|
||||
"updateAvailable": "更新が利用可能",
|
||||
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
|
||||
"currentVersion": "現在のバージョン",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
|
||||
"enable": "ナイトリー更新を有効にする"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近の通知",
|
||||
"empty": "最近のバナーはありません。",
|
||||
"shown": "{time} に表示",
|
||||
"dismissed": "{time} に非表示",
|
||||
"active": "アクティブ"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました",
|
||||
"bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました",
|
||||
"bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました",
|
||||
"bulkContentRatingUpdating": "{count} 件のモデルのコンテンツレーティングを更新中...",
|
||||
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
|
||||
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
|
||||
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
|
||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||
"bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません",
|
||||
"bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました",
|
||||
"bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}",
|
||||
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
|
||||
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
|
||||
"renameFailed": "ファイル名の変更に失敗しました:{message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "コンパクトモード {state}",
|
||||
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
||||
"displayDensitySet": "表示密度が {density} に設定されました",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "言語の変更に失敗しました:{message}",
|
||||
"cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。",
|
||||
"cacheClearFailed": "キャッシュのクリアに失敗しました:{error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "ダウンロードの一時停止に失敗しました:{error}",
|
||||
"downloadResumed": "ダウンロードが再開されました",
|
||||
"resumeFailed": "ダウンロードの再開に失敗しました:{error}",
|
||||
"downloadStopped": "ダウンロードをキャンセルしました",
|
||||
"stopFailed": "ダウンロードのキャンセルに失敗しました:{error}",
|
||||
"deleted": "例画像が削除されました",
|
||||
"deleteFailed": "例画像の削除に失敗しました",
|
||||
"setPreviewFailed": "プレビュー画像の設定に失敗しました"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "今すぐ更新",
|
||||
"refreshingIn": "更新まで",
|
||||
"seconds": "秒"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
206
locales/ko.json
206
locales/ko.json
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 바이트",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint 이름 복사됨",
|
||||
"toggleBlur": "블러 토글",
|
||||
"show": "보기",
|
||||
"openExampleImages": "예시 이미지 폴더 열기"
|
||||
"openExampleImages": "예시 이미지 폴더 열기",
|
||||
"replacePreview": "미리보기 교체",
|
||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||
"copyEmbeddingName": "Embedding 이름 복사",
|
||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "성인 콘텐츠",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "즐겨찾기 상태 업데이트 실패"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능"
|
||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능",
|
||||
"missingPath": "이 카드의 모델 경로를 확인할 수 없습니다"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "예시 이미지 확인 중 오류",
|
||||
"missingHash": "모델 해시 정보가 없습니다.",
|
||||
"noRemoteImagesAvailable": "Civitai에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
|
||||
},
|
||||
"badges": {
|
||||
"update": "업데이트",
|
||||
"updateAvailable": "업데이트 가능"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "예시 이미지를 다운로드하기 전에 다운로드 위치를 설정하세요.",
|
||||
"unavailable": "예시 이미지 다운로드는 아직 사용할 수 없습니다. 페이지 로딩이 완료된 후 다시 시도하세요."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "업데이트 확인",
|
||||
"loading": "{type} 업데이트를 확인 중...",
|
||||
"success": "{type} 업데이트 {count}개를 찾았습니다",
|
||||
"none": "모든 {type}가 최신 상태입니다",
|
||||
"error": "{type} 업데이트 확인 실패: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "예시 이미지 폴더 정리",
|
||||
"success": "{count}개의 폴더가 삭제 폴더로 이동되었습니다",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"notifications": "알림",
|
||||
"support": "지원"
|
||||
}
|
||||
},
|
||||
@@ -187,6 +206,12 @@
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "설정 폴더 열기",
|
||||
"tooltip": "settings.json이 있는 폴더를 엽니다",
|
||||
"success": "settings.json 폴더를 열었습니다",
|
||||
"failed": "settings.json 폴더를 열지 못했습니다"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "콘텐츠 필터링",
|
||||
"videoSettings": "비디오 설정",
|
||||
@@ -196,7 +221,8 @@
|
||||
"exampleImages": "예시 이미지",
|
||||
"misc": "기타",
|
||||
"metadataArchive": "메타데이터 아카이브 데이터베이스",
|
||||
"proxySettings": "프록시 설정"
|
||||
"proxySettings": "프록시 설정",
|
||||
"priorityTags": "우선순위 태그"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||
@@ -217,23 +243,37 @@
|
||||
},
|
||||
"displayDensityHelp": "한 줄에 표시할 카드 수를 선택하세요:",
|
||||
"displayDensityDetails": {
|
||||
"default": "기본: 5개 (1080p), 6개 (2K), 8개 (4K)",
|
||||
"medium": "중간: 6개 (1080p), 7개 (2K), 9개 (4K)",
|
||||
"compact": "조밀: 7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||
"default": "5개 (1080p), 6개 (2K), 8개 (4K)",
|
||||
"medium": "6개 (1080p), 7개 (2K), 9개 (4K)",
|
||||
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
||||
"showFolderSidebar": "폴더 사이드바 표시",
|
||||
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
||||
"cardInfoDisplay": "카드 정보 표시",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "항상 표시",
|
||||
"hover": "호버 시 표시"
|
||||
},
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "항상 표시: 헤더와 푸터가 항상 보입니다",
|
||||
"hover": "호버 시 표시: 카드에 마우스를 올렸을 때만 헤더와 푸터가 나타납니다"
|
||||
}
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "예시 이미지 열기",
|
||||
"replacePreview": "미리보기 교체"
|
||||
},
|
||||
"modelCardFooterActionHelp": "카드 우측 하단 버튼이 수행할 작업을 선택하세요",
|
||||
"modelNameDisplay": "모델명 표시",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "모델명",
|
||||
"fileName": "파일명"
|
||||
},
|
||||
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "활성 라이브러리",
|
||||
"activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다.",
|
||||
"loadingLibraries": "라이브러리를 불러오는 중...",
|
||||
"noLibraries": "구성된 라이브러리가 없습니다",
|
||||
"defaultLoraRoot": "기본 LoRA 루트",
|
||||
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
|
||||
"defaultCheckpointRoot": "기본 Checkpoint 루트",
|
||||
@@ -334,6 +374,26 @@
|
||||
"proxyPassword": "비밀번호 (선택사항)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "우선순위 태그",
|
||||
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "우선순위 태그 도움말 열기",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "체크포인트",
|
||||
"embedding": "임베딩"
|
||||
},
|
||||
"saveSuccess": "우선순위 태그가 업데이트되었습니다.",
|
||||
"saveError": "우선순위 태그를 업데이트하지 못했습니다.",
|
||||
"loadingSuggestions": "추천을 불러오는 중...",
|
||||
"validation": {
|
||||
"missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.",
|
||||
"missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.",
|
||||
"duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.",
|
||||
"unknown": "잘못된 우선순위 태그 구성입니다."
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "모델 목록 새로고침",
|
||||
"quick": "빠른 새로고침 (증분)",
|
||||
"full": "전체 재구성 (완전)"
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitai에서 메타데이터 가져오기",
|
||||
@@ -374,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "즐겨찾기만 보기",
|
||||
"action": "즐겨찾기"
|
||||
},
|
||||
"updates": {
|
||||
"title": "업데이트 가능한 모델만 표시",
|
||||
"action": "업데이트",
|
||||
"menuLabel": "업데이트 옵션 표시",
|
||||
"check": "업데이트 확인",
|
||||
"checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -382,8 +451,10 @@
|
||||
"viewSelected": "선택 항목 보기",
|
||||
"addTags": "모두에 태그 추가",
|
||||
"setBaseModel": "모두에 베이스 모델 설정",
|
||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
"autoOrganize": "자동 정리 선택",
|
||||
"deleteAll": "모든 모델 삭제",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Embedding 모델"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "모델 루트",
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
"pinSidebar": "사이드바 고정",
|
||||
"unpinSidebar": "사이드바 고정 해제",
|
||||
"switchToListView": "목록 보기로 전환",
|
||||
"switchToTreeView": "트리 보기로 전환",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다"
|
||||
"recursiveOn": "하위 폴더 검색",
|
||||
"recursiveOff": "현재 폴더만 검색",
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "통계",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "미리보기 이미지 다운로드됨",
|
||||
"downloadingFile": "{type} 파일 다운로드 중",
|
||||
"finalizing": "다운로드 완료 중..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "현재 파일:",
|
||||
"downloading": "다운로드 중: {name}",
|
||||
"transferred": "다운로드됨: {downloaded} / {total}",
|
||||
"transferredSimple": "다운로드됨: {downloaded}",
|
||||
"transferredUnknown": "다운로드됨: --",
|
||||
"speed": "속도: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "콘텐츠 등급 설정",
|
||||
"current": "현재",
|
||||
"multiple": "여러 값",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||
"tip": "나눠서 진행하고 싶다면 벌크 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||
"action": "전체 확인"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "여러 모델에 태그 추가",
|
||||
"description": "다음에 태그를 추가합니다:",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "예시",
|
||||
"description": "모델 설명",
|
||||
"recipes": "레시피"
|
||||
"recipes": "레시피",
|
||||
"versions": "버전"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "예시 이미지 로딩 중...",
|
||||
"description": "모델 설명 로딩 중...",
|
||||
"recipes": "레시피 로딩 중...",
|
||||
"examples": "예시 로딩 중..."
|
||||
"examples": "예시 로딩 중...",
|
||||
"versions": "버전 로딩 중..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||
"media": {
|
||||
"placeholder": "미리보기 없음"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "이름 없는 버전",
|
||||
"noDetails": "추가 정보 없음"
|
||||
},
|
||||
"badges": {
|
||||
"current": "현재 버전",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"newer": "최신 버전",
|
||||
"ignored": "무시됨"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"delete": "삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
"viewLocalVersions": "로컬 버전 모두 보기",
|
||||
"viewLocalTooltip": "곧 제공 예정"
|
||||
},
|
||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||
"error": "버전을 불러오지 못했습니다.",
|
||||
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||
"confirm": {
|
||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "이 모델의 업데이트가 무시됩니다",
|
||||
"modelResumed": "업데이트 추적이 재개되었습니다",
|
||||
"versionIgnored": "이 버전의 업데이트가 무시됩니다",
|
||||
"versionUnignored": "버전이 다시 활성화되었습니다",
|
||||
"versionDeleted": "버전이 삭제되었습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "LoRA를 워크플로로 전송하지 못했습니다",
|
||||
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
|
||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다"
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "업데이트 확인",
|
||||
"notificationsTitle": "알림 센터",
|
||||
"tabs": {
|
||||
"updates": "업데이트",
|
||||
"messages": "메시지"
|
||||
},
|
||||
"updateAvailable": "업데이트 사용 가능",
|
||||
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
|
||||
"currentVersion": "현재 버전",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
|
||||
"enable": "나이틀리 업데이트 활성화"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "최근 알림",
|
||||
"empty": "최근 배너가 없습니다.",
|
||||
"shown": "{time}에 표시",
|
||||
"dismissed": "{time}에 닫힘",
|
||||
"active": "활성"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다",
|
||||
"bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다",
|
||||
"bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다",
|
||||
"bulkContentRatingUpdating": "{count}개 모델의 콘텐츠 등급을 업데이트하는 중...",
|
||||
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
|
||||
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
|
||||
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
|
||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||
"bulkUpdatesMissing": "선택한 {type}이 Civitai 업데이트에 연결되어 있지 않습니다",
|
||||
"bulkUpdatesPartialMissing": "Civitai 링크가 없는 {missing}개의 {type}을 건너뛰었습니다",
|
||||
"bulkUpdatesFailed": "선택한 {type}의 업데이트 확인에 실패했습니다: {message}",
|
||||
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
|
||||
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
|
||||
"renameFailed": "파일 이름 변경 실패: {message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "컴팩트 모드 {state}",
|
||||
"settingSaveFailed": "설정 저장 실패: {message}",
|
||||
"displayDensitySet": "표시 밀도가 {density}로 설정되었습니다",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "언어 변경 실패: {message}",
|
||||
"cacheCleared": "캐시 파일이 성공적으로 지워졌습니다. 다음 작업 시 캐시가 재구축됩니다.",
|
||||
"cacheClearFailed": "캐시 지우기 실패: {error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "다운로드 일시정지 실패: {error}",
|
||||
"downloadResumed": "다운로드가 재개되었습니다",
|
||||
"resumeFailed": "다운로드 재개 실패: {error}",
|
||||
"downloadStopped": "다운로드가 취소되었습니다",
|
||||
"stopFailed": "다운로드 취소 실패: {error}",
|
||||
"deleted": "예시 이미지가 삭제되었습니다",
|
||||
"deleteFailed": "예시 이미지 삭제 실패",
|
||||
"setPreviewFailed": "미리보기 이미지 설정 실패"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "지금 새로고침",
|
||||
"refreshingIn": "새로고침까지",
|
||||
"seconds": "초"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
206
locales/ru.json
206
locales/ru.json
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Байт",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "Имя checkpoint скопировано",
|
||||
"toggleBlur": "Переключить размытие",
|
||||
"show": "Показать",
|
||||
"openExampleImages": "Открыть папку с примерами"
|
||||
"openExampleImages": "Открыть папку с примерами",
|
||||
"replacePreview": "Заменить превью",
|
||||
"copyCheckpointName": "Копировать имя checkpoint",
|
||||
"copyEmbeddingName": "Копировать имя embedding",
|
||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "Контент для взрослых",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "Не удалось обновить статус избранного"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована"
|
||||
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована",
|
||||
"missingPath": "Невозможно определить путь модели для этой карточки"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "Ошибка проверки примеров изображений",
|
||||
"missingHash": "Отсутствует хеш модели.",
|
||||
"noRemoteImagesAvailable": "Нет удаленных примеров изображений для этой модели на Civitai"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Обновление",
|
||||
"updateAvailable": "Доступно обновление"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "Укажите место загрузки перед загрузкой примеров изображений.",
|
||||
"unavailable": "Загрузка примеров изображений пока недоступна. Попробуйте снова после полной загрузки страницы."
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "Проверить обновления",
|
||||
"loading": "Проверка обновлений для {type}...",
|
||||
"success": "Найдено {count} обновлений для {type}",
|
||||
"none": "Все {type} актуальны",
|
||||
"error": "Не удалось проверить обновления для {type}: {message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "Очистить папки с примерами изображений",
|
||||
"success": "Перемещено {count} папок в папку удалённых",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "Проверить обновления",
|
||||
"notifications": "Уведомления",
|
||||
"support": "Поддержка"
|
||||
}
|
||||
},
|
||||
@@ -187,6 +206,12 @@
|
||||
"civitaiApiKey": "Ключ API Civitai",
|
||||
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
|
||||
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "Открыть папку настроек",
|
||||
"tooltip": "Открыть папку, содержащую settings.json",
|
||||
"success": "Папка settings.json открыта",
|
||||
"failed": "Не удалось открыть папку settings.json"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "Фильтрация контента",
|
||||
"videoSettings": "Настройки видео",
|
||||
@@ -196,7 +221,8 @@
|
||||
"exampleImages": "Примеры изображений",
|
||||
"misc": "Разное",
|
||||
"metadataArchive": "Архив метаданных",
|
||||
"proxySettings": "Настройки прокси"
|
||||
"proxySettings": "Настройки прокси",
|
||||
"priorityTags": "Приоритетные теги"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "Размывать NSFW контент",
|
||||
@@ -217,23 +243,37 @@
|
||||
},
|
||||
"displayDensityHelp": "Выберите количество карточек для отображения в ряду:",
|
||||
"displayDensityDetails": {
|
||||
"default": "По умолчанию: 5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "Средняя: 6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "Компактная: 7 (1080p), 8 (2K), 10 (4K)"
|
||||
"default": "5 (1080p), 6 (2K), 8 (4K)",
|
||||
"medium": "6 (1080p), 7 (2K), 9 (4K)",
|
||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||
},
|
||||
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
||||
"showFolderSidebar": "Показывать боковую панель папок",
|
||||
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
||||
"cardInfoDisplay": "Отображение информации карточки",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "Всегда видимо",
|
||||
"hover": "Показать при наведении"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "Всегда видимо: Заголовки и подписи всегда видны",
|
||||
"hover": "Показать при наведении: Заголовки и подписи появляются только при наведении на карточку"
|
||||
}
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "Открыть примеры изображений",
|
||||
"replacePreview": "Заменить превью"
|
||||
},
|
||||
"modelCardFooterActionHelp": "Выберите, что делает кнопка в правом нижнем углу карточки",
|
||||
"modelNameDisplay": "Отображение названия модели",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "Название модели",
|
||||
"fileName": "Имя файла"
|
||||
},
|
||||
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "Активная библиотека",
|
||||
"activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу.",
|
||||
"loadingLibraries": "Загрузка библиотек...",
|
||||
"noLibraries": "Библиотеки не настроены",
|
||||
"defaultLoraRoot": "Корневая папка LoRA по умолчанию",
|
||||
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
|
||||
@@ -334,6 +374,26 @@
|
||||
"proxyPassword": "Пароль (необязательно)",
|
||||
"proxyPasswordPlaceholder": "пароль",
|
||||
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "Приоритетные теги",
|
||||
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "Открыть справку по приоритетным тегам",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Чекпойнт",
|
||||
"embedding": "Эмбеддинг"
|
||||
},
|
||||
"saveSuccess": "Приоритетные теги обновлены.",
|
||||
"saveError": "Не удалось обновить приоритетные теги.",
|
||||
"loadingSuggestions": "Загрузка подсказок...",
|
||||
"validation": {
|
||||
"missingClosingParen": "В записи {index} отсутствует закрывающая скобка.",
|
||||
"missingCanonical": "Запись {index} должна содержать каноническое имя тега.",
|
||||
"duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.",
|
||||
"unknown": "Недопустимая конфигурация приоритетных тегов."
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список моделей",
|
||||
"quick": "Быстрое обновление (инкрементальное)",
|
||||
"full": "Полная перестройка (полное)"
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Получить метаданные с Civitai",
|
||||
@@ -374,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "Показать только избранное",
|
||||
"action": "Избранное"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Показывать только модели с доступными обновлениями",
|
||||
"action": "Обновления",
|
||||
"menuLabel": "Показать параметры обновления",
|
||||
"check": "Проверить обновления",
|
||||
"checkTooltip": "Проверка может занять время."
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -382,8 +451,10 @@
|
||||
"viewSelected": "Просмотреть выбранные",
|
||||
"addTags": "Добавить теги ко всем",
|
||||
"setBaseModel": "Установить базовую модель для всех",
|
||||
"setContentRating": "Установить рейтинг контента для всех",
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
"autoOrganize": "Автоматически организовать выбранные",
|
||||
"deleteAll": "Удалить все модели",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Модели Embedding"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень моделей",
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
"pinSidebar": "Закрепить боковую панель",
|
||||
"unpinSidebar": "Открепить боковую панель",
|
||||
"switchToListView": "Переключить на вид списка",
|
||||
"switchToTreeView": "Переключить на древовидный вид",
|
||||
"collapseAllDisabled": "Недоступно в виде списка"
|
||||
"recursiveOn": "Искать во вложенных папках",
|
||||
"recursiveOff": "Искать только в текущей папке",
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "Статистика",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "Превью изображение загружено",
|
||||
"downloadingFile": "Загрузка файла {type}",
|
||||
"finalizing": "Завершение загрузки..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "Текущий файл:",
|
||||
"downloading": "Скачивается: {name}",
|
||||
"transferred": "Скачано: {downloaded} / {total}",
|
||||
"transferredSimple": "Скачано: {downloaded}",
|
||||
"transferredUnknown": "Скачано: --",
|
||||
"speed": "Скорость: {speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "Установить рейтинг контента",
|
||||
"current": "Текущий",
|
||||
"multiple": "Несколько значений",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "моделей будут удалены навсегда.",
|
||||
"action": "Удалить все"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "Проверить обновления для всех {typePlural}?",
|
||||
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
|
||||
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
|
||||
"action": "Проверить всё"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "Добавить теги к нескольким моделям",
|
||||
"description": "Добавить теги к",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "Примеры",
|
||||
"description": "Описание модели",
|
||||
"recipes": "Рецепты"
|
||||
"recipes": "Рецепты",
|
||||
"versions": "Версии"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "Загрузка примеров изображений...",
|
||||
"description": "Загрузка описания модели...",
|
||||
"recipes": "Загрузка рецептов...",
|
||||
"examples": "Загрузка примеров..."
|
||||
"examples": "Загрузка примеров...",
|
||||
"versions": "Загрузка версий..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
||||
"media": {
|
||||
"placeholder": "Нет превью"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "Версия без названия",
|
||||
"noDetails": "Дополнительная информация отсутствует"
|
||||
},
|
||||
"badges": {
|
||||
"current": "Текущая версия",
|
||||
"inLibrary": "В библиотеке",
|
||||
"newer": "Более новая версия",
|
||||
"ignored": "Игнорируется"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"delete": "Удалить",
|
||||
"ignore": "Игнорировать",
|
||||
"unignore": "Перестать игнорировать",
|
||||
"resumeModelUpdates": "Возобновить обновления для этой модели",
|
||||
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
|
||||
"viewLocalVersions": "Показать все локальные версии",
|
||||
"viewLocalTooltip": "Скоро появится"
|
||||
},
|
||||
"empty": "Для этой модели пока нет истории версий.",
|
||||
"error": "Не удалось загрузить версии.",
|
||||
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
|
||||
"confirm": {
|
||||
"delete": "Удалить эту версию из библиотеки?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "Обновления для этой модели игнорируются",
|
||||
"modelResumed": "Отслеживание обновлений возобновлено",
|
||||
"versionIgnored": "Обновления для этой версии игнорируются",
|
||||
"versionUnignored": "Версия снова активна",
|
||||
"versionDeleted": "Версия удалена"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "Не удалось отправить LoRA в workflow",
|
||||
"recipeAdded": "Рецепт добавлен в workflow",
|
||||
"recipeReplaced": "Рецепт заменён в workflow",
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow"
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "Рецепт",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "Проверить обновления",
|
||||
"notificationsTitle": "Центр уведомлений",
|
||||
"tabs": {
|
||||
"updates": "Обновления",
|
||||
"messages": "Сообщения"
|
||||
},
|
||||
"updateAvailable": "Доступно обновление",
|
||||
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
|
||||
"currentVersion": "Текущая версия",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
|
||||
"enable": "Включить ночные обновления"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "Недавние уведомления",
|
||||
"empty": "Недавних баннеров нет.",
|
||||
"shown": "Показано {time}",
|
||||
"dismissed": "Закрыто {time}",
|
||||
"active": "Активно"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей",
|
||||
"bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей",
|
||||
"bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей",
|
||||
"bulkContentRatingUpdating": "Обновление рейтинга контента для {count} модель(ей)...",
|
||||
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
|
||||
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
|
||||
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
|
||||
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
|
||||
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
|
||||
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
|
||||
"bulkUpdatesMissing": "Выбранные {type} не привязаны к обновлениям Civitai",
|
||||
"bulkUpdatesPartialMissing": "Пропущено {missing} выбранных {type} без привязки Civitai",
|
||||
"bulkUpdatesFailed": "Не удалось проверить обновления для выбранных {type}: {message}",
|
||||
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
|
||||
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
|
||||
"renameFailed": "Не удалось переименовать файл: {message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "Компактный режим {state}",
|
||||
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
|
||||
"displayDensitySet": "Плотность отображения установлена на {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "Не удалось изменить язык: {message}",
|
||||
"cacheCleared": "Файлы кэша успешно очищены. Кэш будет пересобран при следующем действии.",
|
||||
"cacheClearFailed": "Не удалось очистить кэш: {error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "Не удалось приостановить загрузку: {error}",
|
||||
"downloadResumed": "Загрузка возобновлена",
|
||||
"resumeFailed": "Не удалось возобновить загрузку: {error}",
|
||||
"downloadStopped": "Загрузка отменена",
|
||||
"stopFailed": "Не удалось отменить загрузку: {error}",
|
||||
"deleted": "Пример изображения удален",
|
||||
"deleteFailed": "Не удалось удалить пример изображения",
|
||||
"setPreviewFailed": "Не удалось установить превью изображение"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "Обновить сейчас",
|
||||
"refreshingIn": "Обновление через",
|
||||
"seconds": "секунд"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"disabled": "已禁用"
|
||||
},
|
||||
"language": {
|
||||
"select": "Language",
|
||||
"select_help": "Choose your preferred language for the interface",
|
||||
"select": "选择语言",
|
||||
"select_help": "选择你喜欢的界面语言",
|
||||
"english": "English",
|
||||
"chinese_simplified": "中文(简体)",
|
||||
"chinese_traditional": "中文(繁体)",
|
||||
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 字节",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "检查点名称已复制",
|
||||
"toggleBlur": "切换模糊",
|
||||
"show": "显示",
|
||||
"openExampleImages": "打开示例图片文件夹"
|
||||
"openExampleImages": "打开示例图片文件夹",
|
||||
"replacePreview": "替换预览",
|
||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||
"copyEmbeddingName": "复制 Embedding 名称",
|
||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成熟内容",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "收藏状态更新失败"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现"
|
||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现",
|
||||
"missingPath": "无法确定此卡片的模型路径"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "检查示例图片时出错",
|
||||
"missingHash": "缺少模型哈希信息。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "请先设置下载位置后再下载示例图片。",
|
||||
"unavailable": "示例图片下载当前不可用。请在页面加载完成后重试。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "检查更新",
|
||||
"loading": "正在检查 {type} 更新...",
|
||||
"success": "找到 {count} 条 {type} 更新",
|
||||
"none": "所有 {type} 均已是最新版本",
|
||||
"error": "检查 {type} 更新失败:{message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "清理示例图片文件夹",
|
||||
"success": "已将 {count} 个文件夹移动到已删除文件夹",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "检查更新",
|
||||
"notifications": "通知",
|
||||
"support": "支持"
|
||||
}
|
||||
},
|
||||
@@ -187,6 +206,12 @@
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "打开设置文件夹",
|
||||
"tooltip": "打开包含 settings.json 的文件夹",
|
||||
"success": "已打开 settings.json 文件夹",
|
||||
"failed": "无法打开 settings.json 文件夹"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "内容过滤",
|
||||
"videoSettings": "视频设置",
|
||||
@@ -196,7 +221,8 @@
|
||||
"exampleImages": "示例图片",
|
||||
"misc": "其他",
|
||||
"metadataArchive": "元数据归档数据库",
|
||||
"proxySettings": "代理设置"
|
||||
"proxySettings": "代理设置",
|
||||
"priorityTags": "优先标签"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "模糊 NSFW 内容",
|
||||
@@ -217,23 +243,37 @@
|
||||
},
|
||||
"displayDensityHelp": "选择每行显示卡片数量:",
|
||||
"displayDensityDetails": {
|
||||
"default": "默认:5(1080p),6(2K),8(4K)",
|
||||
"medium": "中等:6(1080p),7(2K),9(4K)",
|
||||
"compact": "紧凑:7(1080p),8(2K),10(4K)"
|
||||
"default": "5(1080p),6(2K),8(4K)",
|
||||
"medium": "6(1080p),7(2K),9(4K)",
|
||||
"compact": "7(1080p),8(2K),10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
||||
"showFolderSidebar": "显示文件夹侧边栏",
|
||||
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
||||
"cardInfoDisplay": "卡片信息显示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "始终可见",
|
||||
"hover": "悬停时显示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "始终可见:标题和底部始终显示",
|
||||
"hover": "悬停时显示:仅在悬停卡片时显示标题和底部"
|
||||
}
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||
"modelCardFooterAction": "模型卡片按钮操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "打开示例图片",
|
||||
"replacePreview": "替换预览"
|
||||
},
|
||||
"modelCardFooterActionHelp": "选择右下角卡片按钮的功能",
|
||||
"modelNameDisplay": "模型名称显示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "模型名称",
|
||||
"fileName": "文件名"
|
||||
},
|
||||
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "活动库",
|
||||
"activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。",
|
||||
"loadingLibraries": "正在加载库...",
|
||||
"noLibraries": "尚未配置库",
|
||||
"defaultLoraRoot": "默认 LoRA 根目录",
|
||||
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
|
||||
"defaultCheckpointRoot": "默认 Checkpoint 根目录",
|
||||
@@ -334,6 +374,26 @@
|
||||
"proxyPassword": "密码 (可选)",
|
||||
"proxyPasswordPlaceholder": "密码",
|
||||
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "优先标签",
|
||||
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "打开优先标签帮助",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "优先标签已更新。",
|
||||
"saveError": "优先标签更新失败。",
|
||||
"loadingSuggestions": "正在加载建议...",
|
||||
"validation": {
|
||||
"missingClosingParen": "条目 {index} 缺少右括号。",
|
||||
"missingCanonical": "条目 {index} 必须包含规范标签名称。",
|
||||
"duplicateCanonical": "规范标签 \"{tag}\" 出现多次。",
|
||||
"unknown": "优先标签配置无效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新模型列表",
|
||||
"quick": "快速刷新(增量)",
|
||||
"full": "完全重建(完整)"
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "从 Civitai 获取元数据",
|
||||
@@ -374,19 +436,28 @@
|
||||
"favorites": {
|
||||
"title": "仅显示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"updates": {
|
||||
"title": "仅显示可用更新的模型",
|
||||
"action": "更新",
|
||||
"menuLabel": "显示更新选项",
|
||||
"check": "检查更新",
|
||||
"checkTooltip": "检查更新可能耗时。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
"selected": "已选中 {count} 项",
|
||||
"selectedSuffix": "已选中",
|
||||
"viewSelected": "查看已选中",
|
||||
"addTags": "为所有添加标签",
|
||||
"setBaseModel": "为所有设置基础模型",
|
||||
"copyAll": "复制全部语法",
|
||||
"refreshAll": "刷新全部元数据",
|
||||
"moveAll": "全部移动到文件夹",
|
||||
"addTags": "为所选中添加标签",
|
||||
"setBaseModel": "为所选中设置基础模型",
|
||||
"setContentRating": "为所选中设置内容评级",
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
"autoOrganize": "自动整理所选模型",
|
||||
"deleteAll": "删除所有模型",
|
||||
"deleteAll": "删除选中模型",
|
||||
"clear": "清除选择",
|
||||
"autoOrganizeProgress": {
|
||||
"initializing": "正在初始化自动整理...",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "模型根目录",
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
"pinSidebar": "固定侧边栏",
|
||||
"unpinSidebar": "取消固定侧边栏",
|
||||
"switchToListView": "切换到列表视图",
|
||||
"switchToTreeView": "切换到树状视图",
|
||||
"collapseAllDisabled": "列表视图下不可用"
|
||||
"recursiveOn": "搜索子文件夹",
|
||||
"recursiveOff": "仅搜索当前文件夹",
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "统计",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "预览图片已下载",
|
||||
"downloadingFile": "正在下载 {type} 文件",
|
||||
"finalizing": "正在完成下载..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "当前文件:",
|
||||
"downloading": "下载中:{name}",
|
||||
"transferred": "已下载:{downloaded} / {total}",
|
||||
"transferredSimple": "已下载:{downloaded}",
|
||||
"transferredUnknown": "已下载:--",
|
||||
"speed": "速度:{speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "设置内容评级",
|
||||
"current": "当前",
|
||||
"multiple": "多个值",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "模型将被永久删除。",
|
||||
"action": "全部删除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "检查所有 {type} 的更新?",
|
||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||
"action": "检查全部"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "批量添加标签",
|
||||
"description": "为多个模型添加标签",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "示例",
|
||||
"description": "模型描述",
|
||||
"recipes": "配方"
|
||||
"recipes": "配方",
|
||||
"versions": "版本"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "正在加载示例图片...",
|
||||
"description": "正在加载模型描述...",
|
||||
"recipes": "正在加载配方...",
|
||||
"examples": "正在加载示例..."
|
||||
"examples": "正在加载示例...",
|
||||
"versions": "正在加载版本..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在一个位置管理该模型的所有版本。",
|
||||
"media": {
|
||||
"placeholder": "无预览"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "暂无更多信息"
|
||||
},
|
||||
"badges": {
|
||||
"current": "当前版本",
|
||||
"inLibrary": "已在库中",
|
||||
"newer": "较新的版本",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"delete": "删除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
"viewLocalVersions": "查看所有本地版本",
|
||||
"viewLocalTooltip": "敬请期待"
|
||||
},
|
||||
"empty": "该模型还没有版本历史。",
|
||||
"error": "加载版本失败。",
|
||||
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||
"confirm": {
|
||||
"delete": "从库中删除此版本?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "已忽略该模型的更新",
|
||||
"modelResumed": "已恢复更新跟踪",
|
||||
"versionIgnored": "已忽略该版本的更新",
|
||||
"versionUnignored": "已重新启用该版本",
|
||||
"versionDeleted": "版本已删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "发送 LoRA 到工作流失败",
|
||||
"recipeAdded": "配方已追加到工作流",
|
||||
"recipeReplaced": "配方已替换到工作流",
|
||||
"recipeFailedToSend": "发送配方到工作流失败"
|
||||
"recipeFailedToSend": "发送配方到工作流失败",
|
||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||
"noTargetNodeSelected": "未选择目标节点"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "检查更新",
|
||||
"notificationsTitle": "通知中心",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "消息"
|
||||
},
|
||||
"updateAvailable": "更新可用",
|
||||
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
|
||||
"currentVersion": "当前版本",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:Nightly 版本可能包含实验性功能,可能不稳定。",
|
||||
"enable": "启用 Nightly 更新"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最近的通知",
|
||||
"empty": "暂无最近的横幅通知。",
|
||||
"shown": "{time} 显示",
|
||||
"dismissed": "{time} 关闭",
|
||||
"active": "仍在显示"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
|
||||
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
|
||||
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
|
||||
"bulkContentRatingUpdating": "正在为 {count} 个模型更新内容评级...",
|
||||
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
|
||||
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level},{failed} 个失败",
|
||||
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
|
||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||
"bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}",
|
||||
"bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}",
|
||||
"invalidCharactersRemoved": "文件名中的无效字符已移除",
|
||||
"filenameCannotBeEmpty": "文件名不能为空",
|
||||
"renameFailed": "重命名文件失败:{message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "紧凑模式 {state}",
|
||||
"settingSaveFailed": "保存设置失败:{message}",
|
||||
"displayDensitySet": "显示密度已设置为 {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "切换语言失败:{message}",
|
||||
"cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。",
|
||||
"cacheClearFailed": "清除缓存失败:{error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "暂停下载失败:{error}",
|
||||
"downloadResumed": "下载已恢复",
|
||||
"resumeFailed": "恢复下载失败:{error}",
|
||||
"downloadStopped": "下载已取消",
|
||||
"stopFailed": "取消下载失败:{error}",
|
||||
"deleted": "示例图片已删除",
|
||||
"deleteFailed": "删除示例图片失败",
|
||||
"setPreviewFailed": "设置预览图片失败"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "立即刷新",
|
||||
"refreshingIn": "将在",
|
||||
"seconds": "秒后刷新"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "LM 浏览器插件限时优惠 ⚡",
|
||||
"content": "来爱发电为Lora Manager项目发电,支持项目持续开发的同时,获取浏览器插件验证码,按季支付更优惠!支付宝/微信方便支付。感谢支持!🚀",
|
||||
"supportCta": "为LM发电",
|
||||
"learnMore": "浏览器插件教程"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"japanese": "日本語",
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español"
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 位元組",
|
||||
@@ -100,7 +101,12 @@
|
||||
"checkpointNameCopied": "Checkpoint 名稱已複製",
|
||||
"toggleBlur": "切換模糊",
|
||||
"show": "顯示",
|
||||
"openExampleImages": "開啟範例圖片資料夾"
|
||||
"openExampleImages": "開啟範例圖片資料夾",
|
||||
"replacePreview": "更換預覽圖",
|
||||
"copyCheckpointName": "複製檢查點名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||
},
|
||||
"nsfw": {
|
||||
"matureContent": "成熟內容",
|
||||
@@ -114,12 +120,17 @@
|
||||
"updateFailed": "更新收藏狀態失敗"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現"
|
||||
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現",
|
||||
"missingPath": "無法確定此卡片的模型路徑"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "檢查範例圖片時發生錯誤",
|
||||
"missingHash": "缺少模型雜湊資訊。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
"updateAvailable": "有可用更新"
|
||||
}
|
||||
},
|
||||
"globalContextMenu": {
|
||||
@@ -128,6 +139,13 @@
|
||||
"missingPath": "請先設定下載位置再下載範例圖片。",
|
||||
"unavailable": "範例圖片下載目前尚不可用。請在頁面載入完成後再試一次。"
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "檢查更新",
|
||||
"loading": "正在檢查 {type} 更新...",
|
||||
"success": "找到 {count} 個 {type} 更新",
|
||||
"none": "所有 {type} 都是最新版本",
|
||||
"error": "檢查 {type} 更新失敗:{message}"
|
||||
},
|
||||
"cleanupExampleImages": {
|
||||
"label": "清理範例圖片資料夾",
|
||||
"success": "已將 {count} 個資料夾移至已刪除資料夾",
|
||||
@@ -180,6 +198,7 @@
|
||||
},
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
"notifications": "通知",
|
||||
"support": "支援"
|
||||
}
|
||||
},
|
||||
@@ -187,6 +206,12 @@
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"openSettingsFileLocation": {
|
||||
"label": "開啟設定資料夾",
|
||||
"tooltip": "開啟包含 settings.json 的資料夾",
|
||||
"success": "已開啟 settings.json 資料夾",
|
||||
"failed": "無法開啟 settings.json 資料夾"
|
||||
},
|
||||
"sections": {
|
||||
"contentFiltering": "內容過濾",
|
||||
"videoSettings": "影片設定",
|
||||
@@ -196,7 +221,8 @@
|
||||
"exampleImages": "範例圖片",
|
||||
"misc": "其他",
|
||||
"metadataArchive": "中繼資料封存資料庫",
|
||||
"proxySettings": "代理設定"
|
||||
"proxySettings": "代理設定",
|
||||
"priorityTags": "優先標籤"
|
||||
},
|
||||
"contentFiltering": {
|
||||
"blurNsfwContent": "模糊 NSFW 內容",
|
||||
@@ -217,23 +243,37 @@
|
||||
},
|
||||
"displayDensityHelp": "選擇每行顯示卡片數量:",
|
||||
"displayDensityDetails": {
|
||||
"default": "預設:5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "中等:6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "緊湊:7(1080p)、8(2K)、10(4K)"
|
||||
"default": "5(1080p)、6(2K)、8(4K)",
|
||||
"medium": "6(1080p)、7(2K)、9(4K)",
|
||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||
},
|
||||
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
||||
"showFolderSidebar": "顯示資料夾側邊欄",
|
||||
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
||||
"cardInfoDisplay": "卡片資訊顯示",
|
||||
"cardInfoDisplayOptions": {
|
||||
"always": "永遠顯示",
|
||||
"hover": "滑鼠懸停顯示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕:",
|
||||
"cardInfoDisplayDetails": {
|
||||
"always": "永遠顯示:標題與頁腳始終可見",
|
||||
"hover": "滑鼠懸停顯示:標題與頁腳僅在滑鼠懸停時顯示"
|
||||
}
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||
"modelCardFooterActionOptions": {
|
||||
"exampleImages": "開啟範例圖片",
|
||||
"replacePreview": "更換預覽圖"
|
||||
},
|
||||
"modelCardFooterActionHelp": "選擇右下角卡片按鈕的功能",
|
||||
"modelNameDisplay": "模型名稱顯示",
|
||||
"modelNameDisplayOptions": {
|
||||
"modelName": "模型名稱",
|
||||
"fileName": "檔案名稱"
|
||||
},
|
||||
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
|
||||
},
|
||||
"folderSettings": {
|
||||
"activeLibrary": "使用中的資料庫",
|
||||
"activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項會重新載入頁面。",
|
||||
"loadingLibraries": "正在載入資料庫...",
|
||||
"noLibraries": "尚未設定任何資料庫",
|
||||
"defaultLoraRoot": "預設 LoRA 根目錄",
|
||||
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
|
||||
"defaultCheckpointRoot": "預設 Checkpoint 根目錄",
|
||||
@@ -334,6 +374,26 @@
|
||||
"proxyPassword": "密碼(選填)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
|
||||
},
|
||||
"priorityTags": {
|
||||
"title": "優先標籤",
|
||||
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
|
||||
"placeholder": "character, concept, style(toon|toon_style)",
|
||||
"helpLinkLabel": "開啟優先標籤說明",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "優先標籤已更新。",
|
||||
"saveError": "更新優先標籤失敗。",
|
||||
"loadingSuggestions": "正在載入建議...",
|
||||
"validation": {
|
||||
"missingClosingParen": "項目 {index} 缺少右括號。",
|
||||
"missingCanonical": "項目 {index} 必須包含正規標籤名稱。",
|
||||
"duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。",
|
||||
"unknown": "優先標籤設定無效。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"loras": {
|
||||
@@ -352,8 +412,10 @@
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理模型列表",
|
||||
"quick": "快速刷新(增量)",
|
||||
"full": "完整重建(全部)"
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "從 Civitai 取得 metadata",
|
||||
@@ -374,6 +436,13 @@
|
||||
"favorites": {
|
||||
"title": "僅顯示收藏",
|
||||
"action": "收藏"
|
||||
},
|
||||
"updates": {
|
||||
"title": "僅顯示可用更新的模型",
|
||||
"action": "更新",
|
||||
"menuLabel": "顯示更新選項",
|
||||
"check": "檢查更新",
|
||||
"checkTooltip": "檢查更新可能耗時。"
|
||||
}
|
||||
},
|
||||
"bulkOperations": {
|
||||
@@ -382,8 +451,10 @@
|
||||
"viewSelected": "檢視已選取",
|
||||
"addTags": "新增標籤到全部",
|
||||
"setBaseModel": "設定全部基礎模型",
|
||||
"setContentRating": "為全部設定內容分級",
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
"autoOrganize": "自動整理所選模型",
|
||||
"deleteAll": "刪除全部模型",
|
||||
@@ -517,13 +588,19 @@
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "模型根目錄",
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
"pinSidebar": "固定側邊欄",
|
||||
"unpinSidebar": "取消固定側邊欄",
|
||||
"switchToListView": "切換至列表檢視",
|
||||
"switchToTreeView": "切換至樹狀檢視",
|
||||
"collapseAllDisabled": "列表檢視下不可用"
|
||||
"switchToTreeView": "切換到樹狀檢視",
|
||||
"recursiveOn": "搜尋子資料夾",
|
||||
"recursiveOff": "僅搜尋目前資料夾",
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。"
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
"title": "統計",
|
||||
@@ -598,6 +675,14 @@
|
||||
"downloadedPreview": "已下載預覽圖片",
|
||||
"downloadingFile": "正在下載 {type} 檔案",
|
||||
"finalizing": "完成下載中..."
|
||||
},
|
||||
"progress": {
|
||||
"currentFile": "目前檔案:",
|
||||
"downloading": "下載中:{name}",
|
||||
"transferred": "已下載:{downloaded} / {total}",
|
||||
"transferredSimple": "已下載:{downloaded}",
|
||||
"transferredUnknown": "已下載:--",
|
||||
"speed": "速度:{speed}"
|
||||
}
|
||||
},
|
||||
"move": {
|
||||
@@ -606,6 +691,7 @@
|
||||
"contentRating": {
|
||||
"title": "設定內容分級",
|
||||
"current": "目前",
|
||||
"multiple": "多個值",
|
||||
"levels": {
|
||||
"pg": "PG",
|
||||
"pg13": "PG13",
|
||||
@@ -644,6 +730,12 @@
|
||||
"countMessage": "模型將被永久刪除。",
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "要檢查所有 {type} 的更新嗎?",
|
||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||
"action": "全部檢查"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
"title": "新增標籤到多個模型",
|
||||
"description": "新增標籤到",
|
||||
@@ -780,13 +872,55 @@
|
||||
"tabs": {
|
||||
"examples": "範例圖片",
|
||||
"description": "模型描述",
|
||||
"recipes": "配方"
|
||||
"recipes": "配方",
|
||||
"versions": "版本"
|
||||
},
|
||||
"loading": {
|
||||
"exampleImages": "載入範例圖片中...",
|
||||
"description": "載入模型描述中...",
|
||||
"recipes": "載入配方中...",
|
||||
"examples": "載入範例中..."
|
||||
"examples": "載入範例中...",
|
||||
"versions": "載入版本中..."
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||
"media": {
|
||||
"placeholder": "無預覽"
|
||||
},
|
||||
"labels": {
|
||||
"unnamed": "未命名版本",
|
||||
"noDetails": "沒有其他資訊"
|
||||
},
|
||||
"badges": {
|
||||
"current": "目前版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"newer": "較新版本",
|
||||
"ignored": "已忽略"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"delete": "刪除",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
"viewLocalVersions": "檢視所有本地版本",
|
||||
"viewLocalTooltip": "敬請期待"
|
||||
},
|
||||
"empty": "此模型尚無版本歷史。",
|
||||
"error": "載入版本失敗。",
|
||||
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||
"confirm": {
|
||||
"delete": "要從庫中刪除此版本嗎?"
|
||||
},
|
||||
"toast": {
|
||||
"modelIgnored": "已忽略此模型的更新",
|
||||
"modelResumed": "已恢復更新追蹤",
|
||||
"versionIgnored": "已忽略此版本的更新",
|
||||
"versionUnignored": "已重新啟用此版本",
|
||||
"versionDeleted": "已刪除此版本"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -893,7 +1027,9 @@
|
||||
"loraFailedToSend": "傳送 LoRA 到工作流失敗",
|
||||
"recipeAdded": "配方已附加到工作流",
|
||||
"recipeReplaced": "配方已取代於工作流",
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗"
|
||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||
"noTargetNodeSelected": "未選擇目標節點"
|
||||
},
|
||||
"nodeSelector": {
|
||||
"recipe": "配方",
|
||||
@@ -938,6 +1074,11 @@
|
||||
},
|
||||
"update": {
|
||||
"title": "檢查更新",
|
||||
"notificationsTitle": "通知中心",
|
||||
"tabs": {
|
||||
"updates": "更新",
|
||||
"messages": "訊息"
|
||||
},
|
||||
"updateAvailable": "有新版本可用",
|
||||
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
|
||||
"currentVersion": "目前版本",
|
||||
@@ -969,6 +1110,13 @@
|
||||
"nightly": {
|
||||
"warning": "警告:Nightly 版本可能包含實驗性功能且可能不穩定。",
|
||||
"enable": "啟用 Nightly 更新"
|
||||
},
|
||||
"banners": {
|
||||
"recent": "最新通知",
|
||||
"empty": "目前沒有最近的橫幅通知。",
|
||||
"shown": "{time} 顯示",
|
||||
"dismissed": "{time} 關閉",
|
||||
"active": "仍在顯示"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1084,6 +1232,16 @@
|
||||
"bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型",
|
||||
"bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗",
|
||||
"bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗",
|
||||
"bulkContentRatingUpdating": "正在為 {count} 個模型更新內容分級...",
|
||||
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
|
||||
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level},{failed} 個失敗",
|
||||
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
|
||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||
"bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}",
|
||||
"bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}",
|
||||
"invalidCharactersRemoved": "已移除檔名中的無效字元",
|
||||
"filenameCannotBeEmpty": "檔案名稱不可為空",
|
||||
"renameFailed": "重新命名檔案失敗:{message}",
|
||||
@@ -1118,6 +1276,8 @@
|
||||
"compactModeToggled": "緊湊模式已{state}",
|
||||
"settingSaveFailed": "儲存設定失敗:{message}",
|
||||
"displayDensitySet": "顯示密度已設為 {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"languageChangeFailed": "切換語言失敗:{message}",
|
||||
"cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。",
|
||||
"cacheClearFailed": "清除快取失敗:{error}",
|
||||
@@ -1191,6 +1351,8 @@
|
||||
"pauseFailed": "暫停下載失敗:{error}",
|
||||
"downloadResumed": "下載已恢復",
|
||||
"resumeFailed": "恢復下載失敗:{error}",
|
||||
"downloadStopped": "下載已取消",
|
||||
"stopFailed": "取消下載失敗:{error}",
|
||||
"deleted": "範例圖片已刪除",
|
||||
"deleteFailed": "刪除範例圖片失敗",
|
||||
"setPreviewFailed": "設定預覽圖片失敗"
|
||||
@@ -1237,6 +1399,12 @@
|
||||
"refreshNow": "立即重新整理",
|
||||
"refreshingIn": "將於",
|
||||
"seconds": "秒後重新整理"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "node scripts/run_frontend_coverage.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.0.0",
|
||||
|
||||
504
py/config.py
504
py/config.py
@@ -1,16 +1,74 @@
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
import folder_paths # type: ignore
|
||||
from typing import List
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set
|
||||
import logging
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from .utils.settings_paths import ensure_settings_file, load_settings_template
|
||||
|
||||
# Use an environment variable to control standalone mode
|
||||
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_folder_paths_for_comparison(
|
||||
folder_paths: Mapping[str, Iterable[str]]
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Normalize folder paths for comparison across libraries."""
|
||||
|
||||
normalized: Dict[str, Set[str]] = {}
|
||||
for key, values in folder_paths.items():
|
||||
if isinstance(values, str):
|
||||
candidate_values: Iterable[str] = [values]
|
||||
else:
|
||||
try:
|
||||
candidate_values = iter(values)
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
normalized_values: Set[str] = set()
|
||||
for value in candidate_values:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
normalized_values.add(os.path.normcase(os.path.normpath(stripped)))
|
||||
|
||||
if normalized_values:
|
||||
normalized[key] = normalized_values
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_library_folder_paths(
|
||||
library_payload: Mapping[str, Any]
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""Return normalized folder paths extracted from a library payload."""
|
||||
|
||||
folder_paths = library_payload.get("folder_paths")
|
||||
if isinstance(folder_paths, Mapping):
|
||||
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||
return {}
|
||||
|
||||
|
||||
def _get_template_folder_paths() -> Dict[str, Set[str]]:
|
||||
"""Return normalized folder paths defined in the bundled template."""
|
||||
|
||||
template_payload = load_settings_template()
|
||||
if not template_payload:
|
||||
return {}
|
||||
|
||||
folder_paths = template_payload.get("folder_paths")
|
||||
if isinstance(folder_paths, Mapping):
|
||||
return _normalize_folder_paths_for_comparison(folder_paths)
|
||||
return {}
|
||||
|
||||
|
||||
class Config:
|
||||
"""Global configuration for LoRA Manager"""
|
||||
|
||||
@@ -19,9 +77,9 @@ class Config:
|
||||
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
|
||||
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
|
||||
# Path mapping dictionary, target to link mapping
|
||||
self._path_mappings = {}
|
||||
# Static route mapping dictionary, target to route mapping
|
||||
self._route_mappings = {}
|
||||
self._path_mappings: Dict[str, str] = {}
|
||||
# Normalized preview root directories used to validate preview access
|
||||
self._preview_root_paths: Set[Path] = set()
|
||||
self.loras_roots = self._init_lora_paths()
|
||||
self.checkpoints_roots = None
|
||||
self.unet_roots = None
|
||||
@@ -30,45 +88,118 @@ class Config:
|
||||
self.embeddings_roots = self._init_embedding_paths()
|
||||
# Scan symbolic links during initialization
|
||||
self._scan_symbolic_links()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
if not standalone_mode:
|
||||
# Save the paths to settings.json when running in ComfyUI mode
|
||||
self.save_folder_paths_to_settings()
|
||||
|
||||
def save_folder_paths_to_settings(self):
|
||||
"""Save folder paths to settings.json for standalone mode to use later"""
|
||||
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
|
||||
try:
|
||||
# Check if we're running in ComfyUI mode (not standalone)
|
||||
# Load existing settings
|
||||
settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.json')
|
||||
settings = {}
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
|
||||
# Update settings with paths
|
||||
settings['folder_paths'] = {
|
||||
'loras': self.loras_roots,
|
||||
'checkpoints': self.checkpoints_roots,
|
||||
'unet': self.unet_roots,
|
||||
'embeddings': self.embeddings_roots,
|
||||
}
|
||||
|
||||
# Add default roots if there's only one item and key doesn't exist
|
||||
if len(self.loras_roots) == 1 and "default_lora_root" not in settings:
|
||||
settings["default_lora_root"] = self.loras_roots[0]
|
||||
|
||||
if self.checkpoints_roots and len(self.checkpoints_roots) == 1 and "default_checkpoint_root" not in settings:
|
||||
settings["default_checkpoint_root"] = self.checkpoints_roots[0]
|
||||
ensure_settings_file(logger)
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
if self.embeddings_roots and len(self.embeddings_roots) == 1 and "default_embedding_root" not in settings:
|
||||
settings["default_embedding_root"] = self.embeddings_roots[0]
|
||||
|
||||
# Save settings
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
|
||||
logger.info("Saved folder paths to settings.json")
|
||||
settings_service = get_settings_manager()
|
||||
libraries = settings_service.get_libraries()
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
default_library = libraries.get("default", {})
|
||||
|
||||
template_folder_paths = _get_template_folder_paths()
|
||||
default_library_paths: Dict[str, Set[str]] = {}
|
||||
if isinstance(default_library, Mapping):
|
||||
default_library_paths = _normalize_library_folder_paths(default_library)
|
||||
|
||||
libraries_changed = False
|
||||
if (
|
||||
isinstance(default_library, Mapping)
|
||||
and template_folder_paths
|
||||
and default_library_paths == template_folder_paths
|
||||
):
|
||||
if "comfyui" in libraries:
|
||||
try:
|
||||
settings_service.delete_library("default")
|
||||
libraries_changed = True
|
||||
logger.info("Removed template 'default' library entry")
|
||||
except Exception as delete_error:
|
||||
logger.debug(
|
||||
"Failed to delete template 'default' library: %s",
|
||||
delete_error,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
settings_service.rename_library("default", "comfyui")
|
||||
libraries_changed = True
|
||||
logger.info("Renamed template 'default' library to 'comfyui'")
|
||||
except Exception as rename_error:
|
||||
logger.debug(
|
||||
"Failed to rename template 'default' library: %s",
|
||||
rename_error,
|
||||
)
|
||||
|
||||
if libraries_changed:
|
||||
libraries = settings_service.get_libraries()
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
default_library = libraries.get("default", {})
|
||||
|
||||
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 []),
|
||||
}
|
||||
|
||||
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
|
||||
|
||||
normalized_default_paths: Optional[Dict[str, Set[str]]] = None
|
||||
if isinstance(default_library, Mapping):
|
||||
normalized_default_paths = _normalize_library_folder_paths(default_library)
|
||||
|
||||
if (
|
||||
not comfy_library
|
||||
and default_library
|
||||
and normalized_target_paths
|
||||
and normalized_default_paths == normalized_target_paths
|
||||
):
|
||||
try:
|
||||
settings_service.rename_library("default", "comfyui")
|
||||
logger.info("Renamed legacy 'default' library to 'comfyui'")
|
||||
libraries = settings_service.get_libraries()
|
||||
comfy_library = libraries.get("comfyui", {})
|
||||
except Exception as rename_error:
|
||||
logger.debug(
|
||||
"Failed to rename legacy 'default' library: %s", rename_error
|
||||
)
|
||||
|
||||
default_lora_root = comfy_library.get("default_lora_root", "")
|
||||
if not default_lora_root and len(self.loras_roots) == 1:
|
||||
default_lora_root = self.loras_roots[0]
|
||||
|
||||
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "")
|
||||
if (not default_checkpoint_root and self.checkpoints_roots and
|
||||
len(self.checkpoints_roots) == 1):
|
||||
default_checkpoint_root = self.checkpoints_roots[0]
|
||||
|
||||
default_embedding_root = comfy_library.get("default_embedding_root", "")
|
||||
if (not default_embedding_root and self.embeddings_roots and
|
||||
len(self.embeddings_roots) == 1):
|
||||
default_embedding_root = self.embeddings_roots[0]
|
||||
|
||||
metadata = dict(comfy_library.get("metadata", {}))
|
||||
metadata.setdefault("display_name", "ComfyUI")
|
||||
metadata["source"] = "comfyui"
|
||||
|
||||
settings_service.upsert_library(
|
||||
"comfyui",
|
||||
folder_paths=target_folder_paths,
|
||||
default_lora_root=default_lora_root,
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
metadata=metadata,
|
||||
activate=True,
|
||||
)
|
||||
|
||||
logger.info("Updated 'comfyui' library with current folder paths")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save folder paths: {e}")
|
||||
|
||||
@@ -125,12 +256,65 @@ class Config:
|
||||
# Keep the original mapping: target path -> link path
|
||||
self._path_mappings[normalized_target] = normalized_link
|
||||
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
|
||||
self._preview_root_paths.update(self._expand_preview_root(normalized_target))
|
||||
self._preview_root_paths.update(self._expand_preview_root(normalized_link))
|
||||
|
||||
def add_route_mapping(self, path: str, route: str):
|
||||
"""Add a static route mapping"""
|
||||
normalized_path = os.path.normpath(path).replace(os.sep, '/')
|
||||
self._route_mappings[normalized_path] = route
|
||||
# logger.info(f"Added route mapping: {normalized_path} -> {route}")
|
||||
def _expand_preview_root(self, path: str) -> Set[Path]:
|
||||
"""Return normalized ``Path`` objects representing a preview root."""
|
||||
|
||||
roots: Set[Path] = set()
|
||||
if not path:
|
||||
return roots
|
||||
|
||||
try:
|
||||
raw_path = Path(path).expanduser()
|
||||
except Exception:
|
||||
return roots
|
||||
|
||||
if raw_path.is_absolute():
|
||||
roots.add(raw_path)
|
||||
|
||||
try:
|
||||
resolved = raw_path.resolve(strict=False)
|
||||
except RuntimeError:
|
||||
resolved = raw_path.absolute()
|
||||
roots.add(resolved)
|
||||
|
||||
try:
|
||||
real_path = raw_path.resolve()
|
||||
except (FileNotFoundError, RuntimeError):
|
||||
real_path = resolved
|
||||
roots.add(real_path)
|
||||
|
||||
normalized: Set[Path] = set()
|
||||
for candidate in roots:
|
||||
if candidate.is_absolute():
|
||||
normalized.add(candidate)
|
||||
else:
|
||||
try:
|
||||
normalized.add(candidate.resolve(strict=False))
|
||||
except RuntimeError:
|
||||
normalized.add(candidate.absolute())
|
||||
|
||||
return normalized
|
||||
|
||||
def _rebuild_preview_roots(self) -> None:
|
||||
"""Recompute the cache of directories permitted for previews."""
|
||||
|
||||
preview_roots: Set[Path] = set()
|
||||
|
||||
for root in self.loras_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.base_models_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.embeddings_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(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))
|
||||
|
||||
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
|
||||
|
||||
def map_path_to_link(self, path: str) -> str:
|
||||
"""Map a target path back to its symbolic link path"""
|
||||
@@ -154,31 +338,93 @@ class Config:
|
||||
return mapped_path
|
||||
return link_path
|
||||
|
||||
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
|
||||
dedup: Dict[str, str] = {}
|
||||
for path in raw_paths:
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
normalized = os.path.normpath(path).replace(os.sep, '/')
|
||||
if real_path not in dedup:
|
||||
dedup[real_path] = normalized
|
||||
return dedup
|
||||
|
||||
def _prepare_lora_paths(self, raw_paths: Iterable[str]) -> List[str]:
|
||||
path_map = self._dedupe_existing_paths(raw_paths)
|
||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||
|
||||
for original_path in unique_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
return unique_paths
|
||||
|
||||
def _prepare_checkpoint_paths(
|
||||
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
|
||||
) -> List[str]:
|
||||
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
|
||||
unet_map = self._dedupe_existing_paths(unet_paths)
|
||||
|
||||
merged_map: Dict[str, str] = {}
|
||||
for real_path, original in {**checkpoint_map, **unet_map}.items():
|
||||
if real_path not in merged_map:
|
||||
merged_map[real_path] = original
|
||||
|
||||
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
||||
|
||||
checkpoint_values = set(checkpoint_map.values())
|
||||
unet_values = set(unet_map.values())
|
||||
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values]
|
||||
self.unet_roots = [p for p in unique_paths if p in unet_values]
|
||||
|
||||
for original_path in unique_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
return unique_paths
|
||||
|
||||
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
|
||||
path_map = self._dedupe_existing_paths(raw_paths)
|
||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||
|
||||
for original_path in unique_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
return unique_paths
|
||||
|
||||
def _apply_library_paths(self, folder_paths: Mapping[str, Iterable[str]]) -> None:
|
||||
self._path_mappings.clear()
|
||||
self._preview_root_paths = set()
|
||||
|
||||
lora_paths = folder_paths.get('loras', []) or []
|
||||
checkpoint_paths = folder_paths.get('checkpoints', []) or []
|
||||
unet_paths = folder_paths.get('unet', []) or []
|
||||
embedding_paths = folder_paths.get('embeddings', []) or []
|
||||
|
||||
self.loras_roots = self._prepare_lora_paths(lora_paths)
|
||||
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||
|
||||
self._scan_symbolic_links()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def _init_lora_paths(self) -> List[str]:
|
||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
||||
try:
|
||||
raw_paths = folder_paths.get_folder_paths("loras")
|
||||
|
||||
# Normalize and resolve symlinks, store mapping from resolved -> original
|
||||
path_map = {}
|
||||
for path in raw_paths:
|
||||
if os.path.exists(path):
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
||||
|
||||
# Now sort and use only the deduplicated real paths
|
||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||
unique_paths = self._prepare_lora_paths(raw_paths)
|
||||
logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
||||
|
||||
|
||||
if not unique_paths:
|
||||
logger.warning("No valid loras folders found in ComfyUI configuration")
|
||||
return []
|
||||
|
||||
for original_path in unique_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
|
||||
return unique_paths
|
||||
except Exception as e:
|
||||
logger.warning(f"Error initializing LoRA paths: {e}")
|
||||
@@ -187,52 +433,17 @@ class Config:
|
||||
def _init_checkpoint_paths(self) -> List[str]:
|
||||
"""Initialize and validate checkpoint paths from ComfyUI settings"""
|
||||
try:
|
||||
# Get checkpoint paths from folder_paths
|
||||
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||
raw_unet_paths = folder_paths.get_folder_paths("unet")
|
||||
|
||||
# Normalize and resolve symlinks for checkpoints, store mapping from resolved -> original
|
||||
checkpoint_map = {}
|
||||
for path in raw_checkpoint_paths:
|
||||
if os.path.exists(path):
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
checkpoint_map[real_path] = checkpoint_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
||||
|
||||
# Normalize and resolve symlinks for unet, store mapping from resolved -> original
|
||||
unet_map = {}
|
||||
for path in raw_unet_paths:
|
||||
if os.path.exists(path):
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
unet_map[real_path] = unet_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
||||
|
||||
# Merge both maps and deduplicate by real path
|
||||
merged_map = {}
|
||||
for real_path, orig_path in {**checkpoint_map, **unet_map}.items():
|
||||
if real_path not in merged_map:
|
||||
merged_map[real_path] = orig_path
|
||||
unique_paths = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
|
||||
|
||||
# Now sort and use only the deduplicated real paths
|
||||
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
||||
|
||||
# Split back into checkpoints and unet roots for class properties
|
||||
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_map.values()]
|
||||
self.unet_roots = [p for p in unique_paths if p in unet_map.values()]
|
||||
|
||||
all_paths = unique_paths
|
||||
|
||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
||||
|
||||
if not all_paths:
|
||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
||||
|
||||
if not unique_paths:
|
||||
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
|
||||
return []
|
||||
|
||||
# Initialize path mappings
|
||||
for original_path in all_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
return all_paths
|
||||
|
||||
return unique_paths
|
||||
except Exception as e:
|
||||
logger.warning(f"Error initializing checkpoint paths: {e}")
|
||||
return []
|
||||
@@ -241,27 +452,13 @@ class Config:
|
||||
"""Initialize and validate embedding paths from ComfyUI settings"""
|
||||
try:
|
||||
raw_paths = folder_paths.get_folder_paths("embeddings")
|
||||
|
||||
# Normalize and resolve symlinks, store mapping from resolved -> original
|
||||
path_map = {}
|
||||
for path in raw_paths:
|
||||
if os.path.exists(path):
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
||||
|
||||
# Now sort and use only the deduplicated real paths
|
||||
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
|
||||
unique_paths = self._prepare_embedding_paths(raw_paths)
|
||||
logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
|
||||
|
||||
|
||||
if not unique_paths:
|
||||
logger.warning("No valid embeddings folders found in ComfyUI configuration")
|
||||
return []
|
||||
|
||||
for original_path in unique_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
|
||||
return unique_paths
|
||||
except Exception as e:
|
||||
logger.warning(f"Error initializing embedding paths: {e}")
|
||||
@@ -270,25 +467,62 @@ class Config:
|
||||
def get_preview_static_url(self, preview_path: str) -> str:
|
||||
if not preview_path:
|
||||
return ""
|
||||
|
||||
real_path = os.path.realpath(preview_path).replace(os.sep, '/')
|
||||
|
||||
# Find longest matching path (most specific match)
|
||||
best_match = ""
|
||||
best_route = ""
|
||||
|
||||
for path, route in self._route_mappings.items():
|
||||
if real_path.startswith(path) and len(path) > len(best_match):
|
||||
best_match = path
|
||||
best_route = route
|
||||
|
||||
if best_match:
|
||||
relative_path = os.path.relpath(real_path, best_match).replace(os.sep, '/')
|
||||
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
|
||||
safe_path = '/'.join(safe_parts)
|
||||
return f'{best_route}/{safe_path}'
|
||||
|
||||
return ""
|
||||
|
||||
normalized = os.path.normpath(preview_path).replace(os.sep, '/')
|
||||
encoded_path = urllib.parse.quote(normalized, safe='')
|
||||
return f'/api/lm/previews?path={encoded_path}'
|
||||
|
||||
def is_preview_path_allowed(self, preview_path: str) -> bool:
|
||||
"""Return ``True`` if ``preview_path`` is within an allowed directory."""
|
||||
|
||||
if not preview_path:
|
||||
return False
|
||||
|
||||
try:
|
||||
candidate = Path(preview_path).expanduser().resolve(strict=False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
for root in self._preview_root_paths:
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
def apply_library_settings(self, library_config: Mapping[str, object]) -> None:
|
||||
"""Update runtime paths to match the provided library configuration."""
|
||||
folder_paths = library_config.get('folder_paths') if isinstance(library_config, Mapping) else {}
|
||||
if not isinstance(folder_paths, Mapping):
|
||||
folder_paths = {}
|
||||
|
||||
self._apply_library_paths(folder_paths)
|
||||
|
||||
logger.info(
|
||||
"Applied library settings with %d lora roots, %d checkpoint roots, and %d embedding roots",
|
||||
len(self.loras_roots or []),
|
||||
len(self.base_models_roots or []),
|
||||
len(self.embeddings_roots or []),
|
||||
)
|
||||
|
||||
def get_library_registry_snapshot(self) -> Dict[str, object]:
|
||||
"""Return the current library registry and active library name."""
|
||||
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
settings_service = get_settings_manager()
|
||||
libraries = settings_service.get_libraries()
|
||||
active_library = settings_service.get_active_library_name()
|
||||
return {
|
||||
"active_library": active_library,
|
||||
"libraries": libraries,
|
||||
}
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Failed to collect library registry snapshot: %s", exc)
|
||||
return {"active_library": "", "libraries": {}}
|
||||
|
||||
# Global config instance
|
||||
config = Config()
|
||||
|
||||
@@ -2,7 +2,6 @@ import asyncio
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from server import PromptServer # type: ignore
|
||||
|
||||
from .config import config
|
||||
@@ -11,9 +10,10 @@ from .routes.recipe_routes import RecipeRoutes
|
||||
from .routes.stats_routes import StatsRoutes
|
||||
from .routes.update_routes import UpdateRoutes
|
||||
from .routes.misc_routes import MiscRoutes
|
||||
from .routes.preview_routes import PreviewRoutes
|
||||
from .routes.example_images_routes import ExampleImagesRoutes
|
||||
from .services.service_registry import ServiceRegistry
|
||||
from .services.settings_manager import settings
|
||||
from .services.settings_manager import get_settings_manager
|
||||
from .utils.example_images_migration import ExampleImagesMigration
|
||||
from .services.websocket_manager import ws_manager
|
||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||
@@ -23,6 +23,37 @@ logger = logging.getLogger(__name__)
|
||||
# Check if we're in standalone mode
|
||||
STANDALONE_MODE = 'nodes' not in sys.modules
|
||||
|
||||
HEADER_SIZE_LIMIT = 16384
|
||||
|
||||
|
||||
def _sanitize_size_limit(value):
|
||||
"""Return a non-negative integer size for ``handler_args`` comparisons."""
|
||||
|
||||
try:
|
||||
coerced = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return coerced if coerced >= 0 else 0
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
def __init__(self):
|
||||
self._manager = None
|
||||
|
||||
def _resolve(self):
|
||||
if self._manager is None:
|
||||
self._manager = get_settings_manager()
|
||||
return self._manager
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return self._resolve().get(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._resolve(), item)
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
|
||||
class LoraManager:
|
||||
"""Main entry point for LoRA Manager plugin"""
|
||||
|
||||
@@ -31,6 +62,24 @@ class LoraManager:
|
||||
"""Initialize and register all routes using the new refactored architecture"""
|
||||
app = PromptServer.instance.app
|
||||
|
||||
# Increase allowed header sizes so browsers with large localhost cookie
|
||||
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
|
||||
# limits. Cookies for unrelated apps are still sent to the plugin and
|
||||
# may otherwise raise LineTooLong errors when the request parser reads
|
||||
# them. Preserve any previously configured handler arguments while
|
||||
# ensuring our minimum sizes are applied.
|
||||
handler_args = getattr(app, "_handler_args", {}) or {}
|
||||
updated_handler_args = dict(handler_args)
|
||||
updated_handler_args["max_field_size"] = max(
|
||||
_sanitize_size_limit(handler_args.get("max_field_size", 0)),
|
||||
HEADER_SIZE_LIMIT,
|
||||
)
|
||||
updated_handler_args["max_line_size"] = max(
|
||||
_sanitize_size_limit(handler_args.get("max_line_size", 0)),
|
||||
HEADER_SIZE_LIMIT,
|
||||
)
|
||||
app._handler_args = updated_handler_args
|
||||
|
||||
# Configure aiohttp access logger to be less verbose
|
||||
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
|
||||
|
||||
@@ -50,102 +99,12 @@ class LoraManager:
|
||||
asyncio_logger = logging.getLogger("asyncio")
|
||||
asyncio_logger.addFilter(ConnectionResetFilter())
|
||||
|
||||
added_targets = set() # Track already added target paths
|
||||
|
||||
# Add static route for example images if the path exists in settings
|
||||
example_images_path = settings.get('example_images_path')
|
||||
logger.info(f"Example images path: {example_images_path}")
|
||||
if example_images_path and os.path.exists(example_images_path):
|
||||
app.router.add_static('/example_images_static', example_images_path)
|
||||
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
|
||||
|
||||
# Add static routes for each lora root
|
||||
for idx, root in enumerate(config.loras_roots, start=1):
|
||||
preview_path = f'/loras_static/root{idx}/preview'
|
||||
|
||||
real_root = root
|
||||
if root in config._path_mappings.values():
|
||||
for target, link in config._path_mappings.items():
|
||||
if link == root:
|
||||
real_root = target
|
||||
break
|
||||
# Add static route for original path
|
||||
app.router.add_static(preview_path, real_root)
|
||||
logger.info(f"Added static route {preview_path} -> {real_root}")
|
||||
|
||||
# Record route mapping
|
||||
config.add_route_mapping(real_root, preview_path)
|
||||
added_targets.add(real_root)
|
||||
|
||||
# Add static routes for each checkpoint root
|
||||
for idx, root in enumerate(config.base_models_roots, start=1):
|
||||
preview_path = f'/checkpoints_static/root{idx}/preview'
|
||||
|
||||
real_root = root
|
||||
if root in config._path_mappings.values():
|
||||
for target, link in config._path_mappings.items():
|
||||
if link == root:
|
||||
real_root = target
|
||||
break
|
||||
# Add static route for original path
|
||||
app.router.add_static(preview_path, real_root)
|
||||
logger.info(f"Added static route {preview_path} -> {real_root}")
|
||||
|
||||
# Record route mapping
|
||||
config.add_route_mapping(real_root, preview_path)
|
||||
added_targets.add(real_root)
|
||||
|
||||
# Add static routes for each embedding root
|
||||
for idx, root in enumerate(config.embeddings_roots, start=1):
|
||||
preview_path = f'/embeddings_static/root{idx}/preview'
|
||||
|
||||
real_root = root
|
||||
if root in config._path_mappings.values():
|
||||
for target, link in config._path_mappings.items():
|
||||
if link == root:
|
||||
real_root = target
|
||||
break
|
||||
# Add static route for original path
|
||||
app.router.add_static(preview_path, real_root)
|
||||
logger.info(f"Added static route {preview_path} -> {real_root}")
|
||||
|
||||
# Record route mapping
|
||||
config.add_route_mapping(real_root, preview_path)
|
||||
added_targets.add(real_root)
|
||||
|
||||
# Add static routes for symlink target paths
|
||||
link_idx = {
|
||||
'lora': 1,
|
||||
'checkpoint': 1,
|
||||
'embedding': 1
|
||||
}
|
||||
|
||||
for target_path, link_path in config._path_mappings.items():
|
||||
if target_path not in added_targets:
|
||||
# Determine if this is a checkpoint, lora, or embedding link based on path
|
||||
is_checkpoint = any(cp_root in link_path for cp_root in config.base_models_roots)
|
||||
is_checkpoint = is_checkpoint or any(cp_root in target_path for cp_root in config.base_models_roots)
|
||||
is_embedding = any(emb_root in link_path for emb_root in config.embeddings_roots)
|
||||
is_embedding = is_embedding or any(emb_root in target_path for emb_root in config.embeddings_roots)
|
||||
|
||||
if is_checkpoint:
|
||||
route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview'
|
||||
link_idx["checkpoint"] += 1
|
||||
elif is_embedding:
|
||||
route_path = f'/embeddings_static/link_{link_idx["embedding"]}/preview'
|
||||
link_idx["embedding"] += 1
|
||||
else:
|
||||
route_path = f'/loras_static/link_{link_idx["lora"]}/preview'
|
||||
link_idx["lora"] += 1
|
||||
|
||||
try:
|
||||
app.router.add_static(route_path, Path(target_path).resolve(strict=False))
|
||||
logger.info(f"Added static route for link target {route_path} -> {target_path}")
|
||||
config.add_route_mapping(target_path, route_path)
|
||||
added_targets.add(target_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to add static route on initialization for {target_path}: {e}")
|
||||
continue
|
||||
|
||||
# Add static route for locales JSON files
|
||||
if os.path.exists(config.i18n_path):
|
||||
@@ -168,6 +127,7 @@ class LoraManager:
|
||||
UpdateRoutes.setup_routes(app)
|
||||
MiscRoutes.setup_routes(app)
|
||||
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||
PreviewRoutes.setup_routes(app)
|
||||
|
||||
# Setup WebSocket routes that are shared across all model types
|
||||
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
|
||||
# Check if running in standalone mode
|
||||
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
|
||||
if not standalone_mode:
|
||||
from .metadata_hook import MetadataHook
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
from .constants import IMAGES
|
||||
|
||||
# Check if running in standalone mode
|
||||
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
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
|
||||
|
||||
|
||||
@@ -3,6 +3,18 @@ import os
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||
|
||||
|
||||
def _store_checkpoint_metadata(metadata, node_id, model_name):
|
||||
"""Store checkpoint model information when available."""
|
||||
if not model_name:
|
||||
return
|
||||
metadata.setdefault(MODELS, {})
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
|
||||
|
||||
class NodeMetadataExtractor:
|
||||
"""Base class for node-specific metadata extraction"""
|
||||
|
||||
@@ -29,12 +41,36 @@ class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
return
|
||||
|
||||
model_name = inputs.get("ckpt_name")
|
||||
if model_name:
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
|
||||
class NunchakuFluxDiTLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "model_path" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("model_path")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
|
||||
class NunchakuQwenImageDiTLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "model_name" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("model_name")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
class GGUFLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "gguf_name" not in inputs:
|
||||
return
|
||||
|
||||
model_name = inputs.get("gguf_name")
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
@@ -43,12 +79,7 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||
return
|
||||
|
||||
model_name = inputs.get("ckpt_name")
|
||||
if model_name:
|
||||
metadata[MODELS][node_id] = {
|
||||
"name": model_name,
|
||||
"type": "checkpoint",
|
||||
"node_id": node_id
|
||||
}
|
||||
_store_checkpoint_metadata(metadata, node_id, model_name)
|
||||
|
||||
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
|
||||
active_loras = []
|
||||
@@ -660,12 +691,17 @@ NODE_EXTRACTORS = {
|
||||
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
|
||||
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
|
||||
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
|
||||
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
"NunchakuQwenImageDiTLoader": NunchakuQwenImageDiTLoaderExtractor, # ComfyUI-Nunchaku
|
||||
"LoaderGGUF": GGUFLoaderExtractor, # calcuis gguf
|
||||
"LoaderGGUFAdvanced": GGUFLoaderExtractor, # calcuis gguf
|
||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
||||
# Conditioning
|
||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||
"PromptLoraManager": CLIPTextEncodeExtractor,
|
||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
from nodes import LoraLoader
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
|
||||
|
||||
@@ -17,7 +16,7 @@ class LoraManagerLoader:
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
# "clip": ("CLIP",),
|
||||
"text": (IO.STRING, {
|
||||
"text": ("STRING", {
|
||||
"multiline": True,
|
||||
"pysssss.autocomplete": False,
|
||||
"dynamicPrompts": True,
|
||||
@@ -28,7 +27,7 @@ class LoraManagerLoader:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
@@ -141,8 +140,7 @@ class LoraManagerTextLoader:
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"lora_syntax": (IO.STRING, {
|
||||
"defaultInput": True,
|
||||
"lora_syntax": ("STRING", {
|
||||
"forceInput": True,
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
|
||||
}),
|
||||
@@ -153,7 +151,7 @@ class LoraManagerTextLoader:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras_from_text"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
||||
@@ -15,7 +14,7 @@ class LoraStacker:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (IO.STRING, {
|
||||
"text": ("STRING", {
|
||||
"multiline": True,
|
||||
"pysssss.autocomplete": False,
|
||||
"dynamicPrompts": True,
|
||||
@@ -26,7 +25,7 @@ class LoraStacker:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LORA_STACK", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||
FUNCTION = "stack_loras"
|
||||
|
||||
|
||||
59
py/nodes/prompt.py
Normal file
59
py/nodes/prompt.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
class PromptLoraManager:
|
||||
"""Encodes text (and optional trigger words) into CLIP conditioning."""
|
||||
|
||||
NAME = "Prompt (LoraManager)"
|
||||
CATEGORY = "Lora Manager/conditioning"
|
||||
DESCRIPTION = (
|
||||
"Encodes a text prompt using a CLIP model into an embedding that can be used "
|
||||
"to guide the diffusion model towards generating specific images."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
'STRING',
|
||||
{
|
||||
"multiline": True,
|
||||
"pysssss.autocomplete": False,
|
||||
"dynamicPrompts": True,
|
||||
"tooltip": "The text to be encoded.",
|
||||
},
|
||||
),
|
||||
"clip": (
|
||||
'CLIP',
|
||||
{"tooltip": "The CLIP model used for encoding the text."},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"trigger_words": (
|
||||
'STRING',
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": (
|
||||
"Optional trigger words to prepend to the text before "
|
||||
"encoding."
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ('CONDITIONING', 'STRING',)
|
||||
RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"A conditioning containing the embedded text used to guide the diffusion model.",
|
||||
)
|
||||
FUNCTION = "encode"
|
||||
|
||||
def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
|
||||
prompt = text
|
||||
if trigger_words:
|
||||
prompt = ", ".join([trigger_words, text])
|
||||
|
||||
from nodes import CLIPTextEncode # type: ignore
|
||||
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||
return (conditioning, prompt,)
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
from server import PromptServer # type: ignore
|
||||
from .utils import FlexibleOptionalInputType, any_type
|
||||
import logging
|
||||
|
||||
|
||||
@@ -110,10 +110,14 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||
model_wrapper.model = transformer
|
||||
ret_model_wrapper.model = transformer
|
||||
|
||||
# Get full path to the LoRA file
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
|
||||
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
|
||||
if not lora_path or not os.path.isfile(lora_path):
|
||||
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
|
||||
return model
|
||||
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
|
||||
|
||||
# Convert the LoRA to diffusers format
|
||||
sd = to_diffusers(lora_path)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||
@@ -16,7 +15,7 @@ class WanVideoLoraSelect:
|
||||
"required": {
|
||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||
"merge_loras": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||
"text": (IO.STRING, {
|
||||
"text": ("STRING", {
|
||||
"multiline": True,
|
||||
"pysssss.autocomplete": False,
|
||||
"dynamicPrompts": True,
|
||||
@@ -27,7 +26,7 @@ class WanVideoLoraSelect:
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
FUNCTION = "process_loras"
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from comfy.comfy_types import IO
|
||||
import folder_paths
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import any_type
|
||||
import logging
|
||||
@@ -20,9 +19,8 @@ class WanVideoLoraSelectFromText:
|
||||
"required": {
|
||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
|
||||
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
|
||||
"lora_syntax": (IO.STRING, {
|
||||
"lora_syntax": ("STRING", {
|
||||
"multiline": True,
|
||||
"defaultInput": True,
|
||||
"forceInput": True,
|
||||
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
|
||||
}),
|
||||
@@ -34,7 +32,7 @@ class WanVideoLoraSelectFromText:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
||||
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
|
||||
FUNCTION = "process_loras_from_syntax"
|
||||
|
||||
@@ -284,7 +284,59 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}")
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
|
||||
# If we found LoRA hashes in the metadata but haven't already
|
||||
# populated entries for them, fall back to creating LoRAs from
|
||||
# the hashes section. Some Civitai image responses only include
|
||||
# LoRA information here without explicit resources entries.
|
||||
for lora_name, lora_hash in lora_hashes.items():
|
||||
if not lora_hash:
|
||||
continue
|
||||
|
||||
# Skip LoRAs we've already added via resources or other fields
|
||||
if lora_hash in added_loras:
|
||||
continue
|
||||
|
||||
lora_entry = {
|
||||
'name': lora_name,
|
||||
'type': "lora",
|
||||
'weight': 1.0,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
continue
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
if 'id' in lora_entry and lora_entry['id']:
|
||||
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}")
|
||||
|
||||
added_loras[lora_hash] = len(result["loras"])
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
|
||||
lora_index = 0
|
||||
while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Dict, Mapping
|
||||
from typing import TYPE_CHECKING, Callable, Dict, Mapping
|
||||
|
||||
import jinja2
|
||||
from aiohttp import web
|
||||
@@ -17,7 +17,7 @@ from ..services.model_lifecycle_service import ModelLifecycleService
|
||||
from ..services.preview_asset_service import PreviewAssetService
|
||||
from ..services.server_i18n import server_i18n as default_server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import settings as default_settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.tag_update_service import TagUpdateService
|
||||
from ..services.websocket_manager import ws_manager as default_ws_manager
|
||||
from ..services.use_cases import (
|
||||
@@ -42,8 +42,12 @@ from .handlers.model_handlers import (
|
||||
ModelMoveHandler,
|
||||
ModelPageView,
|
||||
ModelQueryHandler,
|
||||
ModelUpdateHandler,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..services.model_update_service import ModelUpdateService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,14 +60,14 @@ class BaseModelRoutes(ABC):
|
||||
self,
|
||||
service=None,
|
||||
*,
|
||||
settings_service=default_settings,
|
||||
settings_service=None,
|
||||
ws_manager=default_ws_manager,
|
||||
server_i18n=default_server_i18n,
|
||||
metadata_provider_factory=get_default_metadata_provider,
|
||||
) -> None:
|
||||
self.service = None
|
||||
self.model_type = ""
|
||||
self._settings = settings_service
|
||||
self._settings = settings_service or get_settings_manager()
|
||||
self._ws_manager = ws_manager
|
||||
self._server_i18n = server_i18n
|
||||
self._metadata_provider_factory = metadata_provider_factory
|
||||
@@ -90,7 +94,7 @@ class BaseModelRoutes(ABC):
|
||||
self._metadata_sync_service = MetadataSyncService(
|
||||
metadata_manager=MetadataManager,
|
||||
preview_service=self._preview_service,
|
||||
settings=settings_service,
|
||||
settings=self._settings,
|
||||
default_metadata_provider_factory=metadata_provider_factory,
|
||||
metadata_provider_selector=get_metadata_provider,
|
||||
)
|
||||
@@ -99,10 +103,18 @@ class BaseModelRoutes(ABC):
|
||||
ws_manager=self._ws_manager,
|
||||
download_manager_factory=ServiceRegistry.get_download_manager,
|
||||
)
|
||||
self._model_update_service: ModelUpdateService | None = None
|
||||
|
||||
if service is not None:
|
||||
self.attach_service(service)
|
||||
|
||||
def set_model_update_service(self, service: "ModelUpdateService") -> None:
|
||||
"""Attach the model update tracking service."""
|
||||
|
||||
self._model_update_service = service
|
||||
self._handler_set = None
|
||||
self._handler_mapping = None
|
||||
|
||||
def attach_service(self, service) -> None:
|
||||
"""Attach a model service and rebuild handler dependencies."""
|
||||
self.service = service
|
||||
@@ -127,6 +139,7 @@ class BaseModelRoutes(ABC):
|
||||
|
||||
def _create_handler_set(self) -> ModelHandlerSet:
|
||||
service = self._ensure_service()
|
||||
update_service = self._ensure_model_update_service()
|
||||
page_view = ModelPageView(
|
||||
template_env=self.template_env,
|
||||
template_name=self.template_name or "",
|
||||
@@ -186,6 +199,12 @@ class BaseModelRoutes(ABC):
|
||||
ws_manager=self._ws_manager,
|
||||
logger=logger,
|
||||
)
|
||||
updates = ModelUpdateHandler(
|
||||
service=service,
|
||||
update_service=update_service,
|
||||
metadata_provider_selector=get_metadata_provider,
|
||||
logger=logger,
|
||||
)
|
||||
return ModelHandlerSet(
|
||||
page_view=page_view,
|
||||
listing=listing,
|
||||
@@ -195,6 +214,7 @@ class BaseModelRoutes(ABC):
|
||||
civitai=civitai,
|
||||
move=move,
|
||||
auto_organize=auto_organize,
|
||||
updates=updates,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -273,3 +293,8 @@ class BaseModelRoutes(ABC):
|
||||
|
||||
return proxy
|
||||
|
||||
def _ensure_model_update_service(self) -> "ModelUpdateService":
|
||||
if self._model_update_service is None:
|
||||
raise RuntimeError("Model update service has not been attached")
|
||||
return self._model_update_service
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from ..services.recipes import (
|
||||
)
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from .handlers.recipe_handlers import (
|
||||
@@ -48,7 +48,7 @@ class BaseRecipeRoutes:
|
||||
self.recipe_scanner = None
|
||||
self.lora_scanner = None
|
||||
self.civitai_client = None
|
||||
self.settings = settings
|
||||
self.settings = get_settings_manager()
|
||||
self.server_i18n = server_i18n
|
||||
self.template_env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||
@@ -134,7 +134,7 @@ class BaseRecipeRoutes:
|
||||
recipe_scanner_getter = lambda: self.recipe_scanner
|
||||
civitai_client_getter = lambda: self.civitai_client
|
||||
|
||||
standalone_mode = os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
if not standalone_mode:
|
||||
from ..metadata_collector import get_metadata # type: ignore[import-not-found]
|
||||
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]
|
||||
|
||||
@@ -20,8 +20,10 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
self.service = CheckpointService(checkpoint_scanner)
|
||||
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = CheckpointService(checkpoint_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
@@ -93,4 +95,4 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
}, status=500)
|
||||
|
||||
@@ -19,8 +19,10 @@ class EmbeddingRoutes(BaseModelRoutes):
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
self.service = EmbeddingService(embedding_scanner)
|
||||
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = EmbeddingService(embedding_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/example-images-status", "get_example_images_status"),
|
||||
RouteDefinition("POST", "/api/lm/pause-example-images", "pause_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/resume-example-images", "resume_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/stop-example-images", "stop_example_images"),
|
||||
RouteDefinition("POST", "/api/lm/open-example-images-folder", "open_example_images_folder"),
|
||||
RouteDefinition("GET", "/api/lm/example-image-files", "get_example_image_files"),
|
||||
RouteDefinition("GET", "/api/lm/has-example-images", "has_example_images"),
|
||||
|
||||
@@ -68,6 +68,13 @@ class ExampleImagesDownloadHandler:
|
||||
except DownloadNotRunningError as exc:
|
||||
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||
|
||||
async def stop_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||
try:
|
||||
result = await self._download_manager.stop_download(request)
|
||||
return web.json_response(result)
|
||||
except DownloadNotRunningError as exc:
|
||||
return web.json_response({'success': False, 'error': str(exc)}, status=400)
|
||||
|
||||
async def force_download_example_images(self, request: web.Request) -> web.StreamResponse:
|
||||
try:
|
||||
payload = await request.json()
|
||||
@@ -149,6 +156,7 @@ class ExampleImagesHandlerSet:
|
||||
"get_example_images_status": self.download.get_example_images_status,
|
||||
"pause_example_images": self.download.pause_example_images,
|
||||
"resume_example_images": self.download.resume_example_images,
|
||||
"stop_example_images": self.download.stop_example_images,
|
||||
"force_download_example_images": self.download.force_download_example_images,
|
||||
"import_example_images": self.management.import_example_images,
|
||||
"delete_example_image": self.management.delete_example_image,
|
||||
|
||||
1111
py/routes/handlers/misc_handlers.py
Normal file
1111
py/routes/handlers/misc_handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable, Dict, Iterable, Mapping, Optional
|
||||
from typing import Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from aiohttp import web
|
||||
import jinja2
|
||||
@@ -29,7 +29,9 @@ from ...services.use_cases import (
|
||||
)
|
||||
from ...services.websocket_manager import WebSocketManager
|
||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||
from ...utils.file_utils import calculate_sha256
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
|
||||
|
||||
class ModelPageView:
|
||||
@@ -164,6 +166,8 @@ class ModelListingHandler:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
update_available_only = request.query.get("update_available_only", "false").lower() == "true"
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -176,6 +180,7 @@ class ModelListingHandler:
|
||||
"search_options": search_options,
|
||||
"hash_filters": hash_filters,
|
||||
"favorites_only": favorites_only,
|
||||
"update_available_only": update_available_only,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
@@ -244,6 +249,8 @@ class ModelManagementHandler:
|
||||
if not model_data.get("sha256"):
|
||||
return web.json_response({"success": False, "error": "No SHA256 hash found"}, status=400)
|
||||
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
|
||||
success, error = await self._metadata_sync.fetch_and_update_model(
|
||||
sha256=model_data["sha256"],
|
||||
file_path=file_path,
|
||||
@@ -755,6 +762,30 @@ class ModelDownloadHandler:
|
||||
self._logger.error("Error cancelling download via GET: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def pause_download_get(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response({"success": False, "error": "Download ID is required"}, status=400)
|
||||
result = await self._download_coordinator.pause_download(download_id)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error pausing download via GET: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def resume_download_get(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.query.get("download_id")
|
||||
if not download_id:
|
||||
return web.json_response({"success": False, "error": "Download ID is required"}, status=400)
|
||||
result = await self._download_coordinator.resume_download(download_id)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error resuming download via GET: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_download_progress(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
download_id = request.match_info.get("download_id")
|
||||
@@ -763,7 +794,23 @@ class ModelDownloadHandler:
|
||||
progress_data = self._ws_manager.get_download_progress(download_id)
|
||||
if progress_data is None:
|
||||
return web.json_response({"success": False, "error": "Download ID not found"}, status=404)
|
||||
return web.json_response({"success": True, "progress": progress_data.get("progress", 0)})
|
||||
response_payload = {
|
||||
"success": True,
|
||||
"progress": progress_data.get("progress", 0),
|
||||
"bytes_downloaded": progress_data.get("bytes_downloaded"),
|
||||
"total_bytes": progress_data.get("total_bytes"),
|
||||
"bytes_per_second": progress_data.get("bytes_per_second", 0.0),
|
||||
}
|
||||
|
||||
status = progress_data.get("status")
|
||||
if status and status != "progress":
|
||||
response_payload["status"] = status
|
||||
if "message" in progress_data:
|
||||
response_payload["message"] = progress_data["message"]
|
||||
elif status is None and "message" in progress_data:
|
||||
response_payload["message"] = progress_data["message"]
|
||||
|
||||
return web.json_response(response_payload)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting download progress: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -813,7 +860,10 @@ class ModelCivitaiHandler:
|
||||
try:
|
||||
model_id = request.match_info["model_id"]
|
||||
metadata_provider = await self._metadata_provider_factory()
|
||||
response = await metadata_provider.get_model_versions(model_id)
|
||||
try:
|
||||
response = await metadata_provider.get_model_versions(model_id)
|
||||
except ResourceNotFoundError:
|
||||
return web.Response(status=404, text="Model not found")
|
||||
if not response or not response.get("modelVersions"):
|
||||
return web.Response(status=404, text="Model not found")
|
||||
|
||||
@@ -825,18 +875,30 @@ class ModelCivitaiHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
version_index = cache.version_index
|
||||
|
||||
for version in versions:
|
||||
model_file = self._find_model_file(version.get("files", [])) if isinstance(version.get("files"), Iterable) else None
|
||||
if model_file:
|
||||
hashes = model_file.get("hashes", {}) if isinstance(model_file, Mapping) else {}
|
||||
sha256 = hashes.get("SHA256") if isinstance(hashes, Mapping) else None
|
||||
if sha256:
|
||||
version["existsLocally"] = self._service.has_hash(sha256)
|
||||
if version["existsLocally"]:
|
||||
version["localPath"] = self._service.get_path_by_hash(sha256)
|
||||
version["modelSizeKB"] = model_file.get("sizeKB") if isinstance(model_file, Mapping) else None
|
||||
version_id = None
|
||||
version_id_raw = version.get("id")
|
||||
if version_id_raw is not None:
|
||||
try:
|
||||
version_id = int(str(version_id_raw))
|
||||
except (TypeError, ValueError):
|
||||
version_id = None
|
||||
|
||||
cache_entry = version_index.get(version_id) if (version_id is not None and version_index) else None
|
||||
version["existsLocally"] = cache_entry is not None
|
||||
if cache_entry and isinstance(cache_entry, Mapping):
|
||||
local_path = cache_entry.get("file_path")
|
||||
if local_path:
|
||||
version["localPath"] = local_path
|
||||
else:
|
||||
version["existsLocally"] = False
|
||||
version.pop("localPath", None)
|
||||
|
||||
model_file = self._find_model_file(version.get("files", [])) if isinstance(version.get("files"), Iterable) else None
|
||||
if model_file and isinstance(model_file, Mapping):
|
||||
version["modelSizeKB"] = model_file.get("sizeKB")
|
||||
return web.json_response(versions)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error fetching %s model versions: %s", self._service.model_type, exc)
|
||||
@@ -962,6 +1024,282 @@ class ModelAutoOrganizeHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class ModelUpdateHandler:
|
||||
"""Handle update tracking requests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
service,
|
||||
update_service,
|
||||
metadata_provider_selector,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
self._service = service
|
||||
self._update_service = update_service
|
||||
self._metadata_provider_selector = metadata_provider_selector
|
||||
self._logger = logger
|
||||
|
||||
async def refresh_model_updates(self, request: web.Request) -> web.Response:
|
||||
payload = await self._read_json(request)
|
||||
force_refresh = self._parse_bool(request.query.get("force")) or self._parse_bool(
|
||||
payload.get("force")
|
||||
)
|
||||
|
||||
raw_model_ids = payload.get("modelIds")
|
||||
if raw_model_ids is None:
|
||||
raw_model_ids = payload.get("model_ids")
|
||||
|
||||
target_model_ids: list[int] = []
|
||||
if isinstance(raw_model_ids, (list, tuple, set)):
|
||||
for value in raw_model_ids:
|
||||
normalized = self._normalize_model_id(value)
|
||||
if normalized is not None:
|
||||
target_model_ids.append(normalized)
|
||||
|
||||
if target_model_ids:
|
||||
target_model_ids = sorted(set(target_model_ids))
|
||||
|
||||
provider = await self._get_civitai_provider()
|
||||
if provider is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Civitai provider not available"}, status=503
|
||||
)
|
||||
|
||||
try:
|
||||
records = await self._update_service.refresh_for_model_type(
|
||||
self._service.model_type,
|
||||
self._service.scanner,
|
||||
provider,
|
||||
force_refresh=force_refresh,
|
||||
target_model_ids=target_model_ids or None,
|
||||
)
|
||||
except RateLimitError as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc) or "Rate limited"}, status=429
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
serialized_records = []
|
||||
for record in records.values():
|
||||
has_update_fn = getattr(record, "has_update", None)
|
||||
if callable(has_update_fn) and has_update_fn():
|
||||
serialized_records.append(self._serialize_record(record))
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"records": serialized_records,
|
||||
}
|
||||
)
|
||||
|
||||
async def set_model_update_ignore(self, request: web.Request) -> web.Response:
|
||||
payload = await self._read_json(request)
|
||||
model_id = self._normalize_model_id(payload.get("modelId"))
|
||||
if model_id is None:
|
||||
return web.json_response({"success": False, "error": "modelId is required"}, status=400)
|
||||
|
||||
should_ignore = self._parse_bool(payload.get("shouldIgnore"))
|
||||
record = await self._update_service.set_should_ignore(
|
||||
self._service.model_type, model_id, should_ignore
|
||||
)
|
||||
return web.json_response({"success": True, "record": self._serialize_record(record)})
|
||||
|
||||
async def set_version_update_ignore(self, request: web.Request) -> web.Response:
|
||||
payload = await self._read_json(request)
|
||||
model_id = self._normalize_model_id(payload.get("modelId"))
|
||||
version_id = self._normalize_model_id(payload.get("versionId"))
|
||||
if model_id is None or version_id is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "modelId and versionId are required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
should_ignore = self._parse_bool(payload.get("shouldIgnore"))
|
||||
record = await self._update_service.set_version_should_ignore(
|
||||
self._service.model_type,
|
||||
model_id,
|
||||
version_id,
|
||||
should_ignore,
|
||||
)
|
||||
overrides = await self._build_version_context(record)
|
||||
return web.json_response(
|
||||
{"success": True, "record": self._serialize_record(record, version_context=overrides)}
|
||||
)
|
||||
|
||||
async def get_model_update_status(self, request: web.Request) -> web.Response:
|
||||
model_id = self._normalize_model_id(request.match_info.get("model_id"))
|
||||
if model_id is None:
|
||||
return web.json_response({"success": False, "error": "model_id must be an integer"}, status=400)
|
||||
|
||||
refresh = self._parse_bool(request.query.get("refresh"))
|
||||
force = self._parse_bool(request.query.get("force"))
|
||||
|
||||
try:
|
||||
record = await self._get_or_refresh_record(model_id, refresh=refresh, force=force)
|
||||
except RateLimitError as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc) or "Rate limited"}, status=429
|
||||
)
|
||||
|
||||
if record is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model not tracked"}, status=404
|
||||
)
|
||||
|
||||
return web.json_response({"success": True, "record": self._serialize_record(record)})
|
||||
|
||||
async def get_model_versions(self, request: web.Request) -> web.Response:
|
||||
model_id = self._normalize_model_id(request.match_info.get("model_id"))
|
||||
if model_id is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "model_id must be an integer"}, status=400
|
||||
)
|
||||
|
||||
refresh = self._parse_bool(request.query.get("refresh"))
|
||||
force = self._parse_bool(request.query.get("force"))
|
||||
|
||||
try:
|
||||
record = await self._get_or_refresh_record(model_id, refresh=refresh, force=force)
|
||||
except RateLimitError as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc) or "Rate limited"}, status=429
|
||||
)
|
||||
|
||||
if record is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model not tracked"}, status=404
|
||||
)
|
||||
|
||||
overrides = await self._build_version_context(record)
|
||||
return web.json_response(
|
||||
{"success": True, "record": self._serialize_record(record, version_context=overrides)}
|
||||
)
|
||||
|
||||
async def _get_or_refresh_record(
|
||||
self, model_id: int, *, refresh: bool, force: bool
|
||||
) -> Optional[object]:
|
||||
record = await self._update_service.get_record(self._service.model_type, model_id)
|
||||
if record and not refresh and not force:
|
||||
return record
|
||||
|
||||
provider = await self._get_civitai_provider()
|
||||
if provider is None:
|
||||
return record
|
||||
|
||||
return await self._update_service.refresh_single_model(
|
||||
self._service.model_type,
|
||||
model_id,
|
||||
self._service.scanner,
|
||||
provider,
|
||||
force_refresh=force or refresh,
|
||||
)
|
||||
|
||||
async def _get_civitai_provider(self):
|
||||
try:
|
||||
return await self._metadata_provider_selector("civitai_api")
|
||||
except Exception as exc: # pragma: no cover - defensive log
|
||||
self._logger.error("Failed to acquire civitai provider: %s", exc, exc_info=True)
|
||||
return None
|
||||
|
||||
async def _read_json(self, request: web.Request) -> Dict:
|
||||
if not request.can_read_body:
|
||||
return {}
|
||||
try:
|
||||
return await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool(value) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"1", "true", "yes"}
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model_id(value) -> Optional[int]:
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _serialize_record(
|
||||
self,
|
||||
record,
|
||||
*,
|
||||
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
|
||||
) -> Dict:
|
||||
context = version_context or {}
|
||||
return {
|
||||
"modelType": record.model_type,
|
||||
"modelId": record.model_id,
|
||||
"largestVersionId": record.largest_version_id,
|
||||
"versionIds": record.version_ids,
|
||||
"inLibraryVersionIds": record.in_library_version_ids,
|
||||
"lastCheckedAt": record.last_checked_at,
|
||||
"shouldIgnore": record.should_ignore_model,
|
||||
"hasUpdate": record.has_update(),
|
||||
"versions": [
|
||||
self._serialize_version(version, context.get(version.version_id))
|
||||
for version in record.versions
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_version(version, context: Optional[Dict[str, Optional[str]]]) -> Dict:
|
||||
context = context or {}
|
||||
preview_override = context.get("preview_override")
|
||||
preview_url = preview_override if preview_override is not None else version.preview_url
|
||||
return {
|
||||
"versionId": version.version_id,
|
||||
"name": version.name,
|
||||
"baseModel": version.base_model,
|
||||
"releasedAt": version.released_at,
|
||||
"sizeBytes": version.size_bytes,
|
||||
"previewUrl": preview_url,
|
||||
"isInLibrary": version.is_in_library,
|
||||
"shouldIgnore": version.should_ignore,
|
||||
"filePath": context.get("file_path"),
|
||||
"fileName": context.get("file_name"),
|
||||
}
|
||||
|
||||
async def _build_version_context(self, record) -> Dict[int, Dict[str, Optional[str]]]:
|
||||
context: Dict[int, Dict[str, Optional[str]]] = {}
|
||||
try:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.debug("Failed to load cache while building preview overrides: %s", exc)
|
||||
return context
|
||||
|
||||
version_index = getattr(cache, "version_index", None)
|
||||
if not version_index:
|
||||
return context
|
||||
|
||||
for version in record.versions:
|
||||
if not version.is_in_library:
|
||||
continue
|
||||
cache_entry = version_index.get(version.version_id)
|
||||
if isinstance(cache_entry, Mapping):
|
||||
preview = cache_entry.get("preview_url")
|
||||
context_entry: Dict[str, Optional[str]] = {
|
||||
"file_path": cache_entry.get("file_path"),
|
||||
"file_name": cache_entry.get("file_name"),
|
||||
"preview_override": None,
|
||||
}
|
||||
if isinstance(preview, str) and preview:
|
||||
context_entry["preview_override"] = config.get_preview_static_url(preview)
|
||||
context[version.version_id] = context_entry
|
||||
return context
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelHandlerSet:
|
||||
"""Aggregate concrete handlers into a flat mapping."""
|
||||
@@ -974,6 +1312,7 @@ class ModelHandlerSet:
|
||||
civitai: ModelCivitaiHandler
|
||||
move: ModelMoveHandler
|
||||
auto_organize: ModelAutoOrganizeHandler
|
||||
updates: ModelUpdateHandler
|
||||
|
||||
def to_route_mapping(self) -> Dict[str, Callable[[web.Request], Awaitable[web.Response]]]:
|
||||
return {
|
||||
@@ -1002,6 +1341,8 @@ class ModelHandlerSet:
|
||||
"download_model": self.download.download_model,
|
||||
"download_model_get": self.download.download_model_get,
|
||||
"cancel_download_get": self.download.cancel_download_get,
|
||||
"pause_download_get": self.download.pause_download_get,
|
||||
"resume_download_get": self.download.resume_download_get,
|
||||
"get_download_progress": self.download.get_download_progress,
|
||||
"get_civitai_versions": self.civitai.get_civitai_versions,
|
||||
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
|
||||
@@ -1016,5 +1357,9 @@ class ModelHandlerSet:
|
||||
"get_model_metadata": self.query.get_model_metadata,
|
||||
"get_model_description": self.query.get_model_description,
|
||||
"get_relative_paths": self.query.get_relative_paths,
|
||||
"refresh_model_updates": self.updates.refresh_model_updates,
|
||||
"set_model_update_ignore": self.updates.set_model_update_ignore,
|
||||
"set_version_update_ignore": self.updates.set_version_update_ignore,
|
||||
"get_model_update_status": self.updates.get_model_update_status,
|
||||
"get_model_versions": self.updates.get_model_versions,
|
||||
}
|
||||
|
||||
|
||||
56
py/routes/handlers/preview_handlers.py
Normal file
56
py/routes/handlers/preview_handlers.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Handlers responsible for serving preview assets dynamically."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...config import config as global_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PreviewHandler:
|
||||
"""Serve preview assets for the active library at request time."""
|
||||
|
||||
def __init__(self, *, config=global_config) -> None:
|
||||
self._config = config
|
||||
|
||||
async def serve_preview(self, request: web.Request) -> web.StreamResponse:
|
||||
"""Return the preview file referenced by the encoded ``path`` query."""
|
||||
|
||||
raw_path = request.query.get("path", "")
|
||||
if not raw_path:
|
||||
raise web.HTTPBadRequest(text="Missing 'path' query parameter")
|
||||
|
||||
try:
|
||||
decoded_path = urllib.parse.unquote(raw_path)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("Failed to decode preview path %s: %s", raw_path, exc)
|
||||
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
|
||||
|
||||
normalized = decoded_path.replace("\\", "/")
|
||||
candidate = Path(normalized)
|
||||
try:
|
||||
resolved = candidate.expanduser().resolve(strict=False)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
|
||||
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
|
||||
|
||||
resolved_str = str(resolved)
|
||||
if not self._config.is_preview_path_allowed(resolved_str):
|
||||
logger.debug("Rejected preview outside allowed roots: %s", resolved_str)
|
||||
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
|
||||
|
||||
if not resolved.is_file():
|
||||
logger.debug("Preview file not found at %s", resolved_str)
|
||||
raise web.HTTPNotFound(text="Preview file not found")
|
||||
|
||||
# aiohttp's FileResponse handles range requests and content headers for us.
|
||||
return web.FileResponse(path=resolved, chunk_size=256 * 1024)
|
||||
|
||||
|
||||
__all__ = ["PreviewHandler"]
|
||||
@@ -206,18 +206,16 @@ class RecipeListingHandler:
|
||||
|
||||
def format_recipe_file_url(self, file_path: str) -> str:
|
||||
try:
|
||||
recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, "/")
|
||||
normalized_path = file_path.replace(os.sep, "/")
|
||||
if normalized_path.startswith(recipes_dir):
|
||||
relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, "/")
|
||||
return f"/loras_static/root1/preview/{relative_path}"
|
||||
|
||||
file_name = os.path.basename(file_path)
|
||||
return f"/loras_static/root1/preview/recipes/{file_name}"
|
||||
normalized_path = os.path.normpath(file_path)
|
||||
static_url = config.get_preview_static_url(normalized_path)
|
||||
if static_url:
|
||||
return static_url
|
||||
except Exception as exc: # pragma: no cover - logging path
|
||||
self._logger.error("Error formatting recipe file URL: %s", exc, exc_info=True)
|
||||
return "/loras_static/images/no-preview.png"
|
||||
|
||||
return "/loras_static/images/no-preview.png"
|
||||
|
||||
|
||||
class RecipeQueryHandler:
|
||||
"""Provide read-only insights on recipe data."""
|
||||
|
||||
@@ -23,8 +23,10 @@ class LoraRoutes(BaseModelRoutes):
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
self.service = LoraService(lora_scanner)
|
||||
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = LoraService(lora_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
@@ -229,11 +231,27 @@ class LoraRoutes(BaseModelRoutes):
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Send update to all connected trigger word toggle nodes
|
||||
for node_id in node_ids:
|
||||
PromptServer.instance.send_sync("trigger_word_update", {
|
||||
"id": node_id,
|
||||
for entry in node_ids:
|
||||
node_identifier = entry
|
||||
graph_identifier = None
|
||||
if isinstance(entry, dict):
|
||||
node_identifier = entry.get("node_id")
|
||||
graph_identifier = entry.get("graph_id")
|
||||
|
||||
try:
|
||||
parsed_node_id = int(node_identifier)
|
||||
except (TypeError, ValueError):
|
||||
parsed_node_id = node_identifier
|
||||
|
||||
payload = {
|
||||
"id": parsed_node_id,
|
||||
"message": trigger_words_text
|
||||
})
|
||||
}
|
||||
|
||||
if graph_identifier is not None:
|
||||
payload["graph_id"] = str(graph_identifier)
|
||||
|
||||
PromptServer.instance.send_sync("trigger_word_update", payload)
|
||||
|
||||
return web.json_response({"success": True})
|
||||
|
||||
|
||||
72
py/routes/misc_route_registrar.py
Normal file
72
py/routes/misc_route_registrar.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Route registrar for miscellaneous endpoints.
|
||||
|
||||
This module mirrors the model route registrar architecture so that
|
||||
miscellaneous endpoints share a consistent registration flow.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Iterable, Mapping
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteDefinition:
|
||||
"""Declarative definition for a HTTP route."""
|
||||
|
||||
method: str
|
||||
path: str
|
||||
handler_name: str
|
||||
|
||||
|
||||
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
|
||||
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
|
||||
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
|
||||
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
|
||||
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
|
||||
RouteDefinition("POST", "/api/lm/update-lora-code", "update_lora_code"),
|
||||
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
|
||||
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
|
||||
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
|
||||
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
|
||||
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
|
||||
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
|
||||
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
|
||||
)
|
||||
|
||||
|
||||
class MiscRouteRegistrar:
|
||||
"""Bind miscellaneous route definitions to an aiohttp router."""
|
||||
|
||||
_METHOD_MAP = {
|
||||
"GET": "add_get",
|
||||
"POST": "add_post",
|
||||
"PUT": "add_put",
|
||||
"DELETE": "add_delete",
|
||||
}
|
||||
|
||||
def __init__(self, app: web.Application) -> None:
|
||||
self._app = app
|
||||
|
||||
def register_routes(
|
||||
self,
|
||||
handler_lookup: Mapping[str, Callable[[web.Request], object]],
|
||||
*,
|
||||
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
|
||||
) -> None:
|
||||
for definition in definitions:
|
||||
self._bind(definition.method, definition.path, handler_lookup[definition.handler_name])
|
||||
|
||||
def _bind(self, method: str, path: str, handler: Callable) -> None:
|
||||
add_method_name = self._METHOD_MAP[method.upper()]
|
||||
add_method = getattr(self._app.router, add_method_name)
|
||||
add_method(path, handler)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,9 +55,16 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/version/{modelVersionId}", "get_civitai_model_by_version"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"),
|
||||
RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"),
|
||||
RouteDefinition("GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"),
|
||||
RouteDefinition("POST", "/api/lm/download-model", "download_model"),
|
||||
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
|
||||
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
|
||||
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
|
||||
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
|
||||
)
|
||||
|
||||
25
py/routes/preview_routes.py
Normal file
25
py/routes/preview_routes.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Route controller for preview asset delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .handlers.preview_handlers import PreviewHandler
|
||||
|
||||
|
||||
class PreviewRoutes:
|
||||
"""Register routes that expose preview assets."""
|
||||
|
||||
def __init__(self, *, handler: PreviewHandler | None = None) -> None:
|
||||
self._handler = handler or PreviewHandler()
|
||||
|
||||
@classmethod
|
||||
def setup_routes(cls, app: web.Application) -> None:
|
||||
controller = cls()
|
||||
controller.register(app)
|
||||
|
||||
def register(self, app: web.Application) -> None:
|
||||
app.router.add_get('/api/lm/previews', self._handler.serve_preview)
|
||||
|
||||
|
||||
__all__ = ["PreviewRoutes"]
|
||||
@@ -8,13 +8,32 @@ from collections import defaultdict, Counter
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from ..config import config
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.usage_stats import UsageStats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
def __init__(self):
|
||||
self._manager = None
|
||||
|
||||
def _resolve(self):
|
||||
if self._manager is None:
|
||||
self._manager = get_settings_manager()
|
||||
return self._manager
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return self._resolve().get(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._resolve(), item)
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
|
||||
class StatsRoutes:
|
||||
"""Route handlers for Statistics page and API endpoints"""
|
||||
|
||||
@@ -66,7 +85,9 @@ class StatsRoutes:
|
||||
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
||||
|
||||
# 获取用户语言设置
|
||||
user_language = settings.get('language', 'en')
|
||||
settings_object = settings
|
||||
user_language = settings_object.get('language', 'en')
|
||||
settings_manager = settings_object if not isinstance(settings_object, _SettingsProxy) else settings_object._resolve()
|
||||
|
||||
# 设置服务端i18n语言
|
||||
server_i18n.set_locale(user_language)
|
||||
@@ -79,7 +100,7 @@ class StatsRoutes:
|
||||
template = self.template_env.get_template('statistics.html')
|
||||
rendered = template.render(
|
||||
is_initializing=is_initializing,
|
||||
settings=settings,
|
||||
settings=settings_manager,
|
||||
request=request,
|
||||
t=server_i18n.get_translation,
|
||||
)
|
||||
|
||||
@@ -5,12 +5,18 @@ import git
|
||||
import zipfile
|
||||
import shutil
|
||||
import tempfile
|
||||
from aiohttp import web
|
||||
import asyncio
|
||||
from aiohttp import web, ClientError
|
||||
from typing import Dict, List
|
||||
|
||||
from ..utils.settings_paths import ensure_settings_file
|
||||
from ..services.downloader import get_downloader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NETWORK_EXCEPTIONS = (ClientError, OSError, asyncio.TimeoutError)
|
||||
|
||||
|
||||
class UpdateRoutes:
|
||||
"""Routes for handling plugin update checks"""
|
||||
|
||||
@@ -63,6 +69,12 @@ class UpdateRoutes:
|
||||
'nightly': nightly
|
||||
})
|
||||
|
||||
except NETWORK_EXCEPTIONS as e:
|
||||
logger.warning("Network unavailable during update check: %s", e)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Network unavailable for update check'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check for updates: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
@@ -111,7 +123,7 @@ class UpdateRoutes:
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
|
||||
settings_path = os.path.join(plugin_root, 'settings.json')
|
||||
settings_path = ensure_settings_file(logger)
|
||||
settings_backup = None
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
@@ -193,8 +205,8 @@ class UpdateRoutes:
|
||||
|
||||
zip_path = tmp_zip_path
|
||||
|
||||
# Skip both settings.json and civitai folder
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai'])
|
||||
# Skip both settings.json, civitai and model cache folder
|
||||
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
|
||||
|
||||
# Extract ZIP to temp dir
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
@@ -283,6 +295,9 @@ class UpdateRoutes:
|
||||
|
||||
return version, changelog
|
||||
|
||||
except NETWORK_EXCEPTIONS as e:
|
||||
logger.warning("Unable to reach GitHub for nightly version: %s", e)
|
||||
return "main", []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching nightly version: {e}", exc_info=True)
|
||||
return "main", []
|
||||
@@ -448,6 +463,9 @@ class UpdateRoutes:
|
||||
|
||||
return version, changelog
|
||||
|
||||
except NETWORK_EXCEPTIONS as e:
|
||||
logger.warning("Unable to reach GitHub for release info: %s", e)
|
||||
return "v0.0.0", []
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching remote version: {e}", exc_info=True)
|
||||
return "v0.0.0", []
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Optional, Type
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional, Type, TYPE_CHECKING
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .model_query import FilterCriteria, ModelCacheRepository, ModelFilterSet, SearchStrategy, SettingsProvider
|
||||
from .settings_manager import settings as default_settings
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .model_update_service import ModelUpdateService
|
||||
|
||||
class BaseModelService(ABC):
|
||||
"""Base service class for all model types"""
|
||||
|
||||
@@ -22,6 +27,7 @@ class BaseModelService(ABC):
|
||||
filter_set: Optional[ModelFilterSet] = None,
|
||||
search_strategy: Optional[SearchStrategy] = None,
|
||||
settings_provider: Optional[SettingsProvider] = None,
|
||||
update_service: Optional["ModelUpdateService"] = None,
|
||||
):
|
||||
"""Initialize the service.
|
||||
|
||||
@@ -33,14 +39,16 @@ class BaseModelService(ABC):
|
||||
filter_set: Filter component controlling folder/tag/favorites logic.
|
||||
search_strategy: Search component for fuzzy/text matching.
|
||||
settings_provider: Settings object; defaults to the global settings manager.
|
||||
update_service: Service used to determine whether models have remote updates available.
|
||||
"""
|
||||
self.model_type = model_type
|
||||
self.scanner = scanner
|
||||
self.metadata_class = metadata_class
|
||||
self.settings = settings_provider or default_settings
|
||||
self.settings = settings_provider or get_settings_manager()
|
||||
self.cache_repository = cache_repository or ModelCacheRepository(scanner)
|
||||
self.filter_set = filter_set or ModelFilterSet(self.settings)
|
||||
self.search_strategy = search_strategy or SearchStrategy()
|
||||
self.update_service = update_service
|
||||
|
||||
async def get_paginated_data(
|
||||
self,
|
||||
@@ -55,36 +63,54 @@ class BaseModelService(ABC):
|
||||
search_options: dict = None,
|
||||
hash_filters: dict = None,
|
||||
favorites_only: bool = False,
|
||||
update_available_only: bool = False,
|
||||
**kwargs,
|
||||
) -> Dict:
|
||||
"""Get paginated and filtered model data"""
|
||||
|
||||
sort_params = self.cache_repository.parse_sort(sort_by)
|
||||
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
|
||||
|
||||
if hash_filters:
|
||||
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
|
||||
return self._paginate(filtered_data, page, page_size)
|
||||
|
||||
filtered_data = await self._apply_common_filters(
|
||||
sorted_data,
|
||||
folder=folder,
|
||||
base_models=base_models,
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
)
|
||||
|
||||
if search:
|
||||
filtered_data = await self._apply_search_filters(
|
||||
filtered_data,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
else:
|
||||
filtered_data = await self._apply_common_filters(
|
||||
sorted_data,
|
||||
folder=folder,
|
||||
base_models=base_models,
|
||||
tags=tags,
|
||||
favorites_only=favorites_only,
|
||||
search_options=search_options,
|
||||
)
|
||||
|
||||
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
||||
if search:
|
||||
filtered_data = await self._apply_search_filters(
|
||||
filtered_data,
|
||||
search,
|
||||
fuzzy_search,
|
||||
search_options,
|
||||
)
|
||||
|
||||
return self._paginate(filtered_data, page, page_size)
|
||||
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
|
||||
|
||||
annotated_for_filter: Optional[List[Dict]] = None
|
||||
if update_available_only:
|
||||
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
||||
filtered_data = [
|
||||
item for item in annotated_for_filter
|
||||
if item.get('update_available')
|
||||
]
|
||||
|
||||
paginated = self._paginate(filtered_data, page, page_size)
|
||||
|
||||
if update_available_only:
|
||||
# Items already include update flags thanks to the pre-filter annotation.
|
||||
paginated['items'] = list(paginated['items'])
|
||||
else:
|
||||
paginated['items'] = await self._annotate_update_flags(
|
||||
paginated['items'],
|
||||
)
|
||||
return paginated
|
||||
|
||||
|
||||
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
|
||||
@@ -143,6 +169,92 @@ class BaseModelService(ABC):
|
||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
||||
return data
|
||||
|
||||
async def _annotate_update_flags(
|
||||
self,
|
||||
items: List[Dict],
|
||||
) -> List[Dict]:
|
||||
"""Attach an update_available flag to each response item.
|
||||
|
||||
Items without a civitai model id default to False.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
annotated = [dict(item) for item in items]
|
||||
|
||||
if self.update_service is None:
|
||||
for item in annotated:
|
||||
item['update_available'] = False
|
||||
return annotated
|
||||
|
||||
id_to_items: Dict[int, List[Dict]] = {}
|
||||
ordered_ids: List[int] = []
|
||||
for item in annotated:
|
||||
model_id = self._extract_model_id(item)
|
||||
if model_id is None:
|
||||
item['update_available'] = False
|
||||
continue
|
||||
if model_id not in id_to_items:
|
||||
id_to_items[model_id] = []
|
||||
ordered_ids.append(model_id)
|
||||
id_to_items[model_id].append(item)
|
||||
|
||||
if not ordered_ids:
|
||||
return annotated
|
||||
|
||||
resolved: Optional[Dict[int, bool]] = None
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
try:
|
||||
resolved = await bulk_method(self.model_type, ordered_ids)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
self.model_type,
|
||||
ordered_ids,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
resolved = None
|
||||
|
||||
if resolved is None:
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id)
|
||||
for model_id in ordered_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
resolved = {}
|
||||
for model_id, result in zip(ordered_ids, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(
|
||||
"Failed to resolve update status for model %s (%s): %s",
|
||||
model_id,
|
||||
self.model_type,
|
||||
result,
|
||||
)
|
||||
continue
|
||||
resolved[model_id] = bool(result)
|
||||
|
||||
for model_id, items_for_id in id_to_items.items():
|
||||
flag = bool(resolved.get(model_id, False))
|
||||
for item in items_for_id:
|
||||
item['update_available'] = flag
|
||||
|
||||
return annotated
|
||||
|
||||
@staticmethod
|
||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
||||
civitai = item.get('civitai') if isinstance(item, dict) else None
|
||||
if not isinstance(civitai, dict):
|
||||
return None
|
||||
try:
|
||||
value = civitai.get('modelId')
|
||||
if value is None:
|
||||
return None
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict:
|
||||
"""Apply pagination to filtered data"""
|
||||
@@ -313,24 +425,24 @@ class BaseModelService(ABC):
|
||||
return {'civitai_url': None, 'model_id': None, 'version_id': None}
|
||||
|
||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
||||
"""Get filtered CivitAI metadata for a model by file path"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model.get('file_path') == file_path:
|
||||
return self.filter_civitai_data(model.get("civitai", {}))
|
||||
|
||||
return None
|
||||
"""Load full metadata for a single model.
|
||||
|
||||
Listing/search endpoints return lightweight cache entries; this method performs
|
||||
a lazy read of the on-disk metadata snapshot when callers need full detail.
|
||||
"""
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
||||
if should_skip or metadata is None:
|
||||
return None
|
||||
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
|
||||
|
||||
|
||||
async def get_model_description(self, file_path: str) -> Optional[str]:
|
||||
"""Get model description by file path"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
for model in cache.raw_data:
|
||||
if model.get('file_path') == file_path:
|
||||
return model.get('modelDescription', '')
|
||||
|
||||
return None
|
||||
"""Return the stored modelDescription field for a model."""
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class)
|
||||
if should_skip or metadata is None:
|
||||
return None
|
||||
return metadata.modelDescription or ''
|
||||
|
||||
|
||||
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
|
||||
"""Search model relative file paths for autocomplete functionality"""
|
||||
@@ -372,4 +484,4 @@ class BaseModelService(ABC):
|
||||
x.lower() # Then alphabetically
|
||||
))
|
||||
|
||||
return matching_paths[:limit]
|
||||
return matching_paths[:limit]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.models import CheckpointMetadata
|
||||
from ..config import config
|
||||
@@ -21,14 +21,33 @@ class CheckpointScanner(ModelScanner):
|
||||
hash_index=ModelHashIndex()
|
||||
)
|
||||
|
||||
def _resolve_model_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
||||
return "checkpoint"
|
||||
|
||||
if config.unet_roots and root_path in config.unet_roots:
|
||||
return "diffusion_model"
|
||||
|
||||
return None
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
if hasattr(metadata, "model_type"):
|
||||
if root_path in config.checkpoints_roots:
|
||||
metadata.model_type = "checkpoint"
|
||||
elif root_path in config.unet_roots:
|
||||
metadata.model_type = "diffusion_model"
|
||||
model_type = self._resolve_model_type(root_path)
|
||||
if model_type:
|
||||
metadata.model_type = model_type
|
||||
return metadata
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
model_type = self._resolve_model_type(
|
||||
self._find_root_for_file(entry.get("file_path"))
|
||||
)
|
||||
if model_type:
|
||||
entry["model_type"] = model_type
|
||||
return entry
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get checkpoint root directories"""
|
||||
return config.base_models_roots
|
||||
return config.base_models_roots
|
||||
|
||||
@@ -11,13 +11,14 @@ logger = logging.getLogger(__name__)
|
||||
class CheckpointService(BaseModelService):
|
||||
"""Checkpoint-specific service implementation"""
|
||||
|
||||
def __init__(self, scanner):
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize Checkpoint service
|
||||
|
||||
Args:
|
||||
scanner: Checkpoint scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("checkpoint", scanner, CheckpointMetadata)
|
||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, checkpoint_data: Dict) -> Dict:
|
||||
"""Format Checkpoint data for API response"""
|
||||
@@ -37,6 +38,7 @@ class CheckpointService(BaseModelService):
|
||||
"notes": checkpoint_data.get("notes", ""),
|
||||
"model_type": checkpoint_data.get("model_type", "checkpoint"),
|
||||
"favorite": checkpoint_data.get("favorite", False),
|
||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
@@ -46,4 +48,4 @@ class CheckpointService(BaseModelService):
|
||||
|
||||
def find_duplicate_filenames(self) -> Dict:
|
||||
"""Find Checkpoints with conflicting filenames"""
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
|
||||
554
py/services/civarchive_client.py
Normal file
554
py/services/civarchive_client.py
Normal file
@@ -0,0 +1,554 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError as exc:
|
||||
BeautifulSoup = None # type: ignore[assignment]
|
||||
_BS4_IMPORT_ERROR = exc
|
||||
else:
|
||||
_BS4_IMPORT_ERROR = None
|
||||
|
||||
def _require_beautifulsoup():
|
||||
if BeautifulSoup is None:
|
||||
raise RuntimeError(
|
||||
"BeautifulSoup (bs4) is required for CivArchive client. "
|
||||
"Install it with 'pip install beautifulsoup4'."
|
||||
) from _BS4_IMPORT_ERROR
|
||||
return BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CivArchiveClient:
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls):
|
||||
"""Get singleton instance of CivArchiveClient"""
|
||||
async with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
|
||||
# Register this client as a metadata provider
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
provider_manager.register_provider('civarchive', CivArchiveModelMetadataProvider(cls._instance), False)
|
||||
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
# Check if already initialized for singleton pattern
|
||||
if hasattr(self, '_initialized'):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self.base_url = "https://civarchive.com/api"
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
path: str,
|
||||
params: Optional[Dict[str, str]] = None
|
||||
) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Call CivArchive API and return JSON payload"""
|
||||
success, payload = await self._make_request(path, params=params)
|
||||
if not success:
|
||||
error = payload if isinstance(payload, str) else "Request failed"
|
||||
return None, error
|
||||
if not isinstance(payload, dict):
|
||||
return None, "Invalid response structure"
|
||||
return payload, None
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[bool, Dict | str]:
|
||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
kwargs: Dict[str, Dict[str, str]] = {}
|
||||
if params:
|
||||
safe_params = {str(key): str(value) for key, value in params.items() if value is not None}
|
||||
if safe_params:
|
||||
kwargs["params"] = safe_params
|
||||
|
||||
success, payload = await downloader.make_request(
|
||||
"GET",
|
||||
f"{self.base_url}{path}",
|
||||
use_auth=False,
|
||||
**kwargs,
|
||||
)
|
||||
if not success and isinstance(payload, RateLimitError):
|
||||
if payload.provider is None:
|
||||
payload.provider = "civarchive_api"
|
||||
raise payload
|
||||
return success, payload
|
||||
|
||||
@staticmethod
|
||||
def _normalize_payload(payload: Dict) -> Dict:
|
||||
"""Unwrap CivArchive responses that wrap content under a data key"""
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
data = payload.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _split_context(payload: Dict) -> Tuple[Dict, Dict, List[Dict]]:
|
||||
"""Separate version payload from surrounding model context"""
|
||||
data = CivArchiveClient._normalize_payload(payload)
|
||||
context: Dict = {}
|
||||
fallback_files: List[Dict] = []
|
||||
version: Dict = {}
|
||||
|
||||
for key, value in data.items():
|
||||
if key in {"version", "model"}:
|
||||
continue
|
||||
context[key] = value
|
||||
|
||||
if isinstance(data.get("version"), dict):
|
||||
version = data["version"]
|
||||
|
||||
model_block = data.get("model")
|
||||
if isinstance(model_block, dict):
|
||||
for key, value in model_block.items():
|
||||
if key == "version":
|
||||
if not version and isinstance(value, dict):
|
||||
version = value
|
||||
continue
|
||||
context.setdefault(key, value)
|
||||
fallback_files = fallback_files or model_block.get("files") or []
|
||||
|
||||
fallback_files = fallback_files or data.get("files") or []
|
||||
return context, version, fallback_files
|
||||
|
||||
@staticmethod
|
||||
def _ensure_list(value) -> List:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if value is None:
|
||||
return []
|
||||
return [value]
|
||||
|
||||
@staticmethod
|
||||
def _build_model_info(context: Dict) -> Dict:
|
||||
tags = context.get("tags")
|
||||
if not isinstance(tags, list):
|
||||
tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags])
|
||||
return {
|
||||
"name": context.get("name"),
|
||||
"type": context.get("type"),
|
||||
"nsfw": bool(context.get("is_nsfw", context.get("nsfw", False))),
|
||||
"description": context.get("description"),
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_creator_info(context: Dict) -> Dict:
|
||||
username = context.get("creator_username") or context.get("username") or ""
|
||||
image = context.get("creator_image") or context.get("creator_avatar") or ""
|
||||
creator: Dict[str, Optional[str]] = {
|
||||
"username": username,
|
||||
"image": image,
|
||||
}
|
||||
if context.get("creator_name"):
|
||||
creator["name"] = context["creator_name"]
|
||||
if context.get("creator_url"):
|
||||
creator["url"] = context["creator_url"]
|
||||
return creator
|
||||
|
||||
@staticmethod
|
||||
def _transform_file_entry(file_data: Dict) -> Dict:
|
||||
mirrors = file_data.get("mirrors") or []
|
||||
if not isinstance(mirrors, list):
|
||||
mirrors = [mirrors]
|
||||
available_mirror = next(
|
||||
(mirror for mirror in mirrors if isinstance(mirror, dict) and mirror.get("deletedAt") is None),
|
||||
None
|
||||
)
|
||||
download_url = file_data.get("downloadUrl")
|
||||
if not download_url and available_mirror:
|
||||
download_url = available_mirror.get("url")
|
||||
name = file_data.get("name")
|
||||
if not name and available_mirror:
|
||||
name = available_mirror.get("filename")
|
||||
|
||||
transformed: Dict = {
|
||||
"id": file_data.get("id"),
|
||||
"sizeKB": file_data.get("sizeKB"),
|
||||
"name": name,
|
||||
"type": file_data.get("type"),
|
||||
"downloadUrl": download_url,
|
||||
"primary": True,
|
||||
# TODO: for some reason is_primary is false in CivArchive response, need to figure this out,
|
||||
# "primary": bool(file_data.get("is_primary", file_data.get("primary", False))),
|
||||
"mirrors": mirrors,
|
||||
}
|
||||
|
||||
sha256 = file_data.get("sha256")
|
||||
if sha256:
|
||||
transformed["hashes"] = {"SHA256": str(sha256).upper()}
|
||||
elif isinstance(file_data.get("hashes"), dict):
|
||||
transformed["hashes"] = file_data["hashes"]
|
||||
|
||||
if "metadata" in file_data:
|
||||
transformed["metadata"] = file_data["metadata"]
|
||||
|
||||
if file_data.get("modelVersionId") is not None:
|
||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||
elif file_data.get("model_version_id") is not None:
|
||||
transformed["modelVersionId"] = file_data.get("model_version_id")
|
||||
|
||||
if file_data.get("modelId") is not None:
|
||||
transformed["modelId"] = file_data.get("modelId")
|
||||
elif file_data.get("model_id") is not None:
|
||||
transformed["modelId"] = file_data.get("model_id")
|
||||
|
||||
return transformed
|
||||
|
||||
def _transform_files(
|
||||
self,
|
||||
files: Optional[List[Dict]],
|
||||
fallback_files: Optional[List[Dict]] = None
|
||||
) -> List[Dict]:
|
||||
candidates: List[Dict] = []
|
||||
if isinstance(files, list) and files:
|
||||
candidates = files
|
||||
elif isinstance(fallback_files, list):
|
||||
candidates = fallback_files
|
||||
|
||||
transformed_files: List[Dict] = []
|
||||
for file_data in candidates:
|
||||
if isinstance(file_data, dict):
|
||||
transformed_files.append(self._transform_file_entry(file_data))
|
||||
return transformed_files
|
||||
|
||||
def _transform_version(
|
||||
self,
|
||||
context: Dict,
|
||||
version: Dict,
|
||||
fallback_files: Optional[List[Dict]] = None
|
||||
) -> Optional[Dict]:
|
||||
if not version:
|
||||
return None
|
||||
|
||||
version_copy = deepcopy(version)
|
||||
version_copy.pop("model", None)
|
||||
version_copy.pop("creator", None)
|
||||
|
||||
if "trigger" in version_copy:
|
||||
triggers = version_copy.pop("trigger")
|
||||
if isinstance(triggers, list):
|
||||
version_copy["trainedWords"] = triggers
|
||||
elif triggers is None:
|
||||
version_copy["trainedWords"] = []
|
||||
else:
|
||||
version_copy["trainedWords"] = [triggers]
|
||||
|
||||
if "trainedWords" in version_copy and isinstance(version_copy["trainedWords"], str):
|
||||
version_copy["trainedWords"] = [version_copy["trainedWords"]]
|
||||
|
||||
if "nsfw_level" in version_copy:
|
||||
version_copy["nsfwLevel"] = version_copy.pop("nsfw_level")
|
||||
elif "nsfwLevel" not in version_copy and context.get("nsfw_level") is not None:
|
||||
version_copy["nsfwLevel"] = context.get("nsfw_level")
|
||||
|
||||
stats_keys = ["downloadCount", "ratingCount", "rating"]
|
||||
stats = {key: version_copy.pop(key) for key in stats_keys if key in version_copy}
|
||||
if stats:
|
||||
version_copy["stats"] = stats
|
||||
|
||||
version_copy["files"] = self._transform_files(version_copy.get("files"), fallback_files)
|
||||
version_copy["images"] = self._ensure_list(version_copy.get("images"))
|
||||
|
||||
version_copy["model"] = self._build_model_info(context)
|
||||
version_copy["creator"] = self._build_creator_info(context)
|
||||
|
||||
version_copy["source"] = "civarchive"
|
||||
version_copy["is_deleted"] = bool(context.get("deletedAt")) or bool(version.get("deletedAt"))
|
||||
|
||||
return version_copy
|
||||
|
||||
async def _resolve_version_from_files(self, payload: Dict) -> Optional[Dict]:
|
||||
"""Fallback to fetch version data when only file metadata is available"""
|
||||
data = self._normalize_payload(payload)
|
||||
files = data.get("files") or payload.get("files") or []
|
||||
if not isinstance(files, list):
|
||||
files = [files]
|
||||
for file_data in files:
|
||||
if not isinstance(file_data, dict):
|
||||
continue
|
||||
model_id = file_data.get("model_id") or file_data.get("modelId")
|
||||
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
|
||||
if model_id is None or version_id is None:
|
||||
continue
|
||||
resolved = await self.get_model_version(model_id, version_id)
|
||||
if resolved:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Find model by SHA256 hash value using CivArchive API"""
|
||||
try:
|
||||
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
|
||||
if error:
|
||||
if "not found" in error.lower():
|
||||
return None, "Model not found"
|
||||
return None, error
|
||||
|
||||
context, version_data, fallback_files = self._split_context(payload)
|
||||
transformed = self._transform_version(context, version_data, fallback_files)
|
||||
if transformed:
|
||||
return transformed, None
|
||||
|
||||
resolved = await self._resolve_version_from_files(payload)
|
||||
if resolved:
|
||||
return resolved, None
|
||||
|
||||
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
|
||||
return None, "No version data found"
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
|
||||
return None, str(e)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model using CivArchive API"""
|
||||
try:
|
||||
payload, error = await self._request_json(f"/models/{model_id}")
|
||||
if error or payload is None:
|
||||
if error and "not found" in error.lower():
|
||||
return None
|
||||
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
|
||||
return None
|
||||
|
||||
data = self._normalize_payload(payload)
|
||||
context, version_data, fallback_files = self._split_context(payload)
|
||||
|
||||
versions_meta = data.get("versions") or []
|
||||
transformed_versions: List[Dict] = []
|
||||
for meta in versions_meta:
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
version_id = meta.get("id")
|
||||
if version_id is None:
|
||||
continue
|
||||
target_model_id = meta.get("modelId") or model_id
|
||||
version = await self.get_model_version(target_model_id, version_id)
|
||||
if version:
|
||||
transformed_versions.append(version)
|
||||
|
||||
# Ensure the primary version is included even if versions list was empty
|
||||
primary_version = self._transform_version(context, version_data, fallback_files)
|
||||
if primary_version:
|
||||
transformed_versions.insert(0, primary_version)
|
||||
|
||||
ordered_versions: List[Dict] = []
|
||||
seen_ids = set()
|
||||
for version in transformed_versions:
|
||||
version_id = version.get("id")
|
||||
if version_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(version_id)
|
||||
ordered_versions.append(version)
|
||||
|
||||
return {
|
||||
"modelVersions": ordered_versions,
|
||||
"type": context.get("type", ""),
|
||||
"name": context.get("name", ""),
|
||||
}
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
"""Get specific model version using CivArchive API
|
||||
|
||||
Args:
|
||||
model_id: The model ID (required)
|
||||
version_id: Optional specific version ID to filter to
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The model version data or None if not found
|
||||
"""
|
||||
if model_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
params = {"modelVersionId": version_id} if version_id is not None else None
|
||||
payload, error = await self._request_json(f"/models/{model_id}", params=params)
|
||||
if error or payload is None:
|
||||
if error and "not found" in error.lower():
|
||||
return None
|
||||
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
|
||||
return None
|
||||
|
||||
context, version_data, fallback_files = self._split_context(payload)
|
||||
|
||||
if not version_data:
|
||||
return await self._resolve_version_from_files(payload)
|
||||
|
||||
if version_id is not None:
|
||||
raw_id = version_data.get("id")
|
||||
if raw_id != version_id:
|
||||
logger.warning(
|
||||
"Requested version %s doesn't match default version %s for model %s",
|
||||
version_id,
|
||||
raw_id,
|
||||
model_id,
|
||||
)
|
||||
return None
|
||||
actual_model_id = version_data.get("modelId")
|
||||
context_model_id = context.get("id")
|
||||
# CivArchive can respond with data for a different model id while already
|
||||
# returning the fully resolved model context. Only follow the redirect when
|
||||
# the context itself still points to the original (wrong) model.
|
||||
if (
|
||||
actual_model_id is not None
|
||||
and str(actual_model_id) != str(model_id)
|
||||
and (context_model_id is None or str(context_model_id) != str(actual_model_id))
|
||||
):
|
||||
return await self.get_model_version(actual_model_id, version_id)
|
||||
|
||||
return self._transform_version(context, version_data, fallback_files)
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
""" Fetch model version metadata using a known bogus model lookup
|
||||
CivArchive lacks a direct version lookup API, this uses a workaround (which we handle in the main model request now)
|
||||
|
||||
Args:
|
||||
version_id: The model version ID
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[Dict], Optional[str]]: (version_data, error_message)
|
||||
"""
|
||||
version = await self.get_model_version(1, version_id)
|
||||
if version is None:
|
||||
return None, "Model not found"
|
||||
return version, None
|
||||
|
||||
async def get_model_by_url(self, url) -> Optional[Dict]:
|
||||
"""Get specific model version by parsing CivArchive HTML page (legacy method)
|
||||
|
||||
This is the original HTML scraping implementation, kept for reference and new sites added not in api.
|
||||
The primary get_model_version() now uses the API instead.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Construct CivArchive URL
|
||||
url = f"https://civarchive.com/{url}"
|
||||
downloader = await get_downloader()
|
||||
session = await downloader.session
|
||||
async with session.get(url) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
|
||||
html_content = await response.text()
|
||||
|
||||
# Parse HTML to extract JSON data
|
||||
soup_parser = _require_beautifulsoup()
|
||||
soup = soup_parser(html_content, 'html.parser')
|
||||
script_tag = soup.find('script', {'id': '__NEXT_DATA__', 'type': 'application/json'})
|
||||
|
||||
if not script_tag:
|
||||
return None
|
||||
|
||||
# Parse JSON content
|
||||
json_data = json.loads(script_tag.string)
|
||||
model_data = json_data.get('props', {}).get('pageProps', {}).get('model')
|
||||
|
||||
if not model_data or 'version' not in model_data:
|
||||
return None
|
||||
|
||||
# Extract version data as base
|
||||
version = model_data['version'].copy()
|
||||
|
||||
# Restructure stats
|
||||
if 'downloadCount' in version and 'ratingCount' in version and 'rating' in version:
|
||||
version['stats'] = {
|
||||
'downloadCount': version.pop('downloadCount'),
|
||||
'ratingCount': version.pop('ratingCount'),
|
||||
'rating': version.pop('rating')
|
||||
}
|
||||
|
||||
# Rename trigger to trainedWords
|
||||
if 'trigger' in version:
|
||||
version['trainedWords'] = version.pop('trigger')
|
||||
|
||||
# Transform files data to expected format
|
||||
if 'files' in version:
|
||||
transformed_files = []
|
||||
for file_data in version['files']:
|
||||
# Find first available mirror (deletedAt is null)
|
||||
available_mirror = None
|
||||
for mirror in file_data.get('mirrors', []):
|
||||
if mirror.get('deletedAt') is None:
|
||||
available_mirror = mirror
|
||||
break
|
||||
|
||||
# Create transformed file entry
|
||||
transformed_file = {
|
||||
'id': file_data.get('id'),
|
||||
'sizeKB': file_data.get('sizeKB'),
|
||||
'name': available_mirror.get('filename', file_data.get('name')) if available_mirror else file_data.get('name'),
|
||||
'type': file_data.get('type'),
|
||||
'downloadUrl': available_mirror.get('url') if available_mirror else None,
|
||||
'primary': file_data.get('is_primary', False),
|
||||
'mirrors': file_data.get('mirrors', [])
|
||||
}
|
||||
|
||||
# Transform hash format
|
||||
if 'sha256' in file_data:
|
||||
transformed_file['hashes'] = {
|
||||
'SHA256': file_data['sha256'].upper()
|
||||
}
|
||||
|
||||
transformed_files.append(transformed_file)
|
||||
|
||||
version['files'] = transformed_files
|
||||
|
||||
# Add model information
|
||||
version['model'] = {
|
||||
'name': model_data.get('name'),
|
||||
'type': model_data.get('type'),
|
||||
'nsfw': model_data.get('is_nsfw', False),
|
||||
'description': model_data.get('description'),
|
||||
'tags': model_data.get('tags', [])
|
||||
}
|
||||
|
||||
version['creator'] = {
|
||||
'username': model_data.get('username'),
|
||||
'image': ''
|
||||
}
|
||||
|
||||
# Add source identifier
|
||||
version['source'] = 'civarchive'
|
||||
version['is_deleted'] = json_data.get('query', {}).get('is_deleted', False)
|
||||
|
||||
return version
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model version (scraping) {url}: {e}")
|
||||
return None
|
||||
@@ -1,10 +1,11 @@
|
||||
import os
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Tuple, List
|
||||
import os
|
||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
||||
from .model_metadata_provider import CivitaiModelMetadataProvider, ModelMetadataProviderManager
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,6 +33,47 @@ class CivitaiClient:
|
||||
self._initialized = True
|
||||
|
||||
self.base_url = "https://civitai.com/api/v1"
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
use_auth: bool = False,
|
||||
**kwargs,
|
||||
) -> Tuple[bool, Dict | str]:
|
||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
method,
|
||||
url,
|
||||
use_auth=use_auth,
|
||||
**kwargs,
|
||||
)
|
||||
if not success and isinstance(result, RateLimitError):
|
||||
if result.provider is None:
|
||||
result.provider = "civitai_api"
|
||||
raise result
|
||||
return success, result
|
||||
|
||||
@staticmethod
|
||||
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
||||
"""Remove Comfy-specific metadata from model version images."""
|
||||
if not isinstance(model_version, dict):
|
||||
return
|
||||
|
||||
images = model_version.get("images")
|
||||
if not isinstance(images, list):
|
||||
return
|
||||
|
||||
for image in images:
|
||||
if not isinstance(image, dict):
|
||||
continue
|
||||
|
||||
meta = image.get("meta")
|
||||
if isinstance(meta, dict) and "comfy" in meta:
|
||||
meta.pop("comfy", None)
|
||||
|
||||
async def download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
|
||||
"""Download file with resumable downloads and retry mechanism
|
||||
@@ -61,8 +103,7 @@ class CivitaiClient:
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
@@ -72,7 +113,7 @@ class CivitaiClient:
|
||||
model_id = result.get('modelId')
|
||||
if model_id:
|
||||
# Fetch additional model metadata
|
||||
success_model, data = await downloader.make_request(
|
||||
success_model, data = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
@@ -81,10 +122,11 @@ class CivitaiClient:
|
||||
# Enrich version_info with model data
|
||||
result['model']['description'] = data.get("description")
|
||||
result['model']['tags'] = data.get("tags", [])
|
||||
|
||||
|
||||
# Add creator from model data
|
||||
result['creator'] = data.get("creator")
|
||||
|
||||
|
||||
self._remove_comfy_metadata(result)
|
||||
return result, None
|
||||
|
||||
# Handle specific error cases
|
||||
@@ -94,6 +136,8 @@ class CivitaiClient:
|
||||
# Other error cases
|
||||
logger.error(f"Failed to fetch model info for {model_hash[:10]}: {result}")
|
||||
return None, str(result)
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"API Error: {str(e)}")
|
||||
return None, str(e)
|
||||
@@ -116,11 +160,32 @@ class CivitaiClient:
|
||||
logger.error(f"Download Error: {str(e)}")
|
||||
return False
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> List[Dict]:
|
||||
@staticmethod
|
||||
def _extract_error_message(payload: Any) -> str:
|
||||
"""Return a human-readable error message from an API payload."""
|
||||
|
||||
def _from_value(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
for key in ("message", "error", "detail", "details"):
|
||||
if key in value:
|
||||
candidate = _from_value(value[key])
|
||||
if candidate:
|
||||
return candidate
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
candidate = _from_value(item)
|
||||
if candidate:
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
return _from_value(payload)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model with local availability info"""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.make_request(
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
@@ -132,145 +197,229 @@ class CivitaiClient:
|
||||
'type': result.get('type', ''),
|
||||
'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:
|
||||
raise RuntimeError(message)
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except ResourceNotFoundError as exc:
|
||||
logger.info("Model %s is no longer available on Civitai: %s", model_id, exc)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model versions: {e}")
|
||||
logger.error("Error fetching model versions: %s", e, exc_info=True)
|
||||
raise
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model metadata for multiple ids using the batch API."""
|
||||
|
||||
deduped: Dict[int, None] = {}
|
||||
for raw_id in model_ids:
|
||||
try:
|
||||
normalized = int(raw_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
deduped.setdefault(normalized, None)
|
||||
|
||||
normalized_ids = [str(model_id) for model_id in deduped.keys()]
|
||||
if not normalized_ids:
|
||||
return {}
|
||||
|
||||
try:
|
||||
query = ",".join(normalized_ids)
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models",
|
||||
use_auth=True,
|
||||
params={'ids': query},
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
items = result.get('items') if isinstance(result, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return {}
|
||||
|
||||
payload: Dict[int, Dict] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
model_id = item.get('id')
|
||||
try:
|
||||
normalized_id = int(model_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
payload[normalized_id] = {
|
||||
'modelVersions': item.get('modelVersions', []),
|
||||
'type': item.get('type', ''),
|
||||
'name': item.get('name', ''),
|
||||
}
|
||||
return payload
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error fetching model versions in bulk: {exc}")
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
"""Get specific model version with additional metadata
|
||||
|
||||
Args:
|
||||
model_id: The Civitai model ID (optional if version_id is provided)
|
||||
version_id: Optional specific version ID to retrieve
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: The model version data with additional fields or None if not found
|
||||
"""
|
||||
"""Get specific model version with additional metadata."""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
|
||||
# Case 1: Only version_id is provided
|
||||
if model_id is None and version_id is not None:
|
||||
# First get the version info to extract model_id
|
||||
success, version = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
model_id = version.get('modelId')
|
||||
if not model_id:
|
||||
logger.error(f"No modelId found in version {version_id}")
|
||||
return None
|
||||
|
||||
# Now get the model data for additional metadata
|
||||
success, model_data = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
# Enrich version with model data
|
||||
version['model']['description'] = model_data.get("description")
|
||||
version['model']['tags'] = model_data.get("tags", [])
|
||||
version['creator'] = model_data.get("creator")
|
||||
|
||||
return version
|
||||
|
||||
# Case 2: model_id is provided (with or without version_id)
|
||||
elif model_id is not None:
|
||||
# Step 1: Get model data to find version_id if not provided and get additional metadata
|
||||
success, data = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
return await self._get_version_by_id_only(version_id)
|
||||
|
||||
model_versions = data.get('modelVersions', [])
|
||||
if not model_versions:
|
||||
logger.warning(f"No model versions found for model {model_id}")
|
||||
return None
|
||||
if model_id is not None:
|
||||
return await self._get_version_with_model_id(model_id, version_id)
|
||||
|
||||
# Step 2: Determine the target version entry to use
|
||||
target_version = None
|
||||
if version_id is not None:
|
||||
target_version = next(
|
||||
(item for item in model_versions if item.get('id') == version_id),
|
||||
None
|
||||
)
|
||||
if target_version is None:
|
||||
logger.warning(
|
||||
f"Version {version_id} not found for model {model_id}, defaulting to first version"
|
||||
)
|
||||
if target_version is None:
|
||||
target_version = model_versions[0]
|
||||
logger.error("Either model_id or version_id must be provided")
|
||||
return None
|
||||
|
||||
target_version_id = target_version.get('id')
|
||||
|
||||
# Step 3: Get detailed version info using the SHA256 hash
|
||||
model_hash = None
|
||||
for file_info in target_version.get('files', []):
|
||||
if file_info.get('type') == 'Model' and file_info.get('primary'):
|
||||
model_hash = file_info.get('hashes', {}).get('SHA256')
|
||||
if model_hash:
|
||||
break
|
||||
|
||||
version = None
|
||||
if model_hash:
|
||||
success, version = await downloader.make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
)
|
||||
if not success:
|
||||
logger.warning(
|
||||
f"Failed to fetch version by hash for model {model_id} version {target_version_id}: {version}"
|
||||
)
|
||||
version = None
|
||||
else:
|
||||
logger.warning(
|
||||
f"No primary model hash found for model {model_id} version {target_version_id}"
|
||||
)
|
||||
|
||||
if version is None:
|
||||
version = copy.deepcopy(target_version)
|
||||
version.pop('index', None)
|
||||
version['modelId'] = model_id
|
||||
version['model'] = {
|
||||
'name': data.get('name'),
|
||||
'type': data.get('type'),
|
||||
'nsfw': data.get('nsfw'),
|
||||
'poi': data.get('poi')
|
||||
}
|
||||
|
||||
# Step 4: Enrich version_info with model data
|
||||
# Add description and tags from model data
|
||||
model_info = version.get('model')
|
||||
if not isinstance(model_info, dict):
|
||||
model_info = {}
|
||||
version['model'] = model_info
|
||||
model_info['description'] = data.get("description")
|
||||
model_info['tags'] = data.get("tags", [])
|
||||
|
||||
# Add creator from model data
|
||||
version['creator'] = data.get("creator")
|
||||
|
||||
return version
|
||||
|
||||
# Case 3: Neither model_id nor version_id provided
|
||||
else:
|
||||
logger.error("Either model_id or version_id must be provided")
|
||||
return None
|
||||
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model version: {e}")
|
||||
return None
|
||||
|
||||
async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict]:
|
||||
version = await self._fetch_version_by_id(version_id)
|
||||
if version is None:
|
||||
return None
|
||||
|
||||
model_id = version.get('modelId')
|
||||
if not model_id:
|
||||
logger.error(f"No modelId found in version {version_id}")
|
||||
return None
|
||||
|
||||
model_data = await self._fetch_model_data(model_id)
|
||||
if model_data:
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
|
||||
self._remove_comfy_metadata(version)
|
||||
return version
|
||||
|
||||
async def _get_version_with_model_id(self, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
|
||||
model_data = await self._fetch_model_data(model_id)
|
||||
if not model_data:
|
||||
return None
|
||||
|
||||
target_version = self._select_target_version(model_data, model_id, version_id)
|
||||
if target_version is None:
|
||||
return None
|
||||
|
||||
target_version_id = target_version.get('id')
|
||||
version = await self._fetch_version_by_id(target_version_id) if target_version_id else None
|
||||
|
||||
if version is None:
|
||||
model_hash = self._extract_primary_model_hash(target_version)
|
||||
if model_hash:
|
||||
version = await self._fetch_version_by_hash(model_hash)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No primary model hash found for model {model_id} version {target_version_id}"
|
||||
)
|
||||
|
||||
if version is None:
|
||||
version = self._build_version_from_model_data(target_version, model_id, model_data)
|
||||
|
||||
self._enrich_version_with_model_data(version, model_data)
|
||||
self._remove_comfy_metadata(version)
|
||||
return version
|
||||
|
||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
||||
success, data = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/models/{model_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
return data
|
||||
logger.warning(f"Failed to fetch model data for model {model_id}")
|
||||
return None
|
||||
|
||||
async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict]:
|
||||
if version_id is None:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/{version_id}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
|
||||
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||
return None
|
||||
|
||||
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict]:
|
||||
if not model_hash:
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
'GET',
|
||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||
use_auth=True
|
||||
)
|
||||
if success:
|
||||
return version
|
||||
|
||||
logger.warning(f"Failed to fetch version by hash {model_hash}")
|
||||
return None
|
||||
|
||||
def _select_target_version(self, model_data: Dict, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
|
||||
model_versions = model_data.get('modelVersions', [])
|
||||
if not model_versions:
|
||||
logger.warning(f"No model versions found for model {model_id}")
|
||||
return None
|
||||
|
||||
if version_id is not None:
|
||||
target_version = next(
|
||||
(item for item in model_versions if item.get('id') == version_id),
|
||||
None
|
||||
)
|
||||
if target_version is None:
|
||||
logger.warning(
|
||||
f"Version {version_id} not found for model {model_id}, defaulting to first version"
|
||||
)
|
||||
return model_versions[0]
|
||||
return target_version
|
||||
|
||||
return model_versions[0]
|
||||
|
||||
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
|
||||
for file_info in version_entry.get('files', []):
|
||||
if file_info.get('type') == 'Model' and file_info.get('primary'):
|
||||
hashes = file_info.get('hashes', {})
|
||||
model_hash = hashes.get('SHA256')
|
||||
if model_hash:
|
||||
return model_hash
|
||||
return None
|
||||
|
||||
def _build_version_from_model_data(self, version_entry: Dict, model_id: int, model_data: Dict) -> Dict:
|
||||
version = copy.deepcopy(version_entry)
|
||||
version.pop('index', None)
|
||||
version['modelId'] = model_id
|
||||
version['model'] = {
|
||||
'name': model_data.get('name'),
|
||||
'type': model_data.get('type'),
|
||||
'nsfw': model_data.get('nsfw'),
|
||||
'poi': model_data.get('poi')
|
||||
}
|
||||
return version
|
||||
|
||||
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None:
|
||||
model_info = version.get('model')
|
||||
if not isinstance(model_info, dict):
|
||||
model_info = {}
|
||||
version['model'] = model_info
|
||||
|
||||
model_info['description'] = model_data.get("description")
|
||||
model_info['tags'] = model_data.get("tags", [])
|
||||
version['creator'] = model_data.get("creator")
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Fetch model version metadata from Civitai
|
||||
|
||||
@@ -283,11 +432,10 @@ class CivitaiClient:
|
||||
- An error message if there was an error, or None on success
|
||||
"""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
url = f"{self.base_url}/model-versions/{version_id}"
|
||||
|
||||
logger.debug(f"Resolving DNS for model version info: {url}")
|
||||
success, result = await downloader.make_request(
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
@@ -295,6 +443,7 @@ class CivitaiClient:
|
||||
|
||||
if success:
|
||||
logger.debug(f"Successfully fetched model version info for: {version_id}")
|
||||
self._remove_comfy_metadata(result)
|
||||
return result, None
|
||||
|
||||
# Handle specific error cases
|
||||
@@ -306,6 +455,8 @@ class CivitaiClient:
|
||||
# Other error cases
|
||||
logger.error(f"Failed to fetch model info for {version_id}: {result}")
|
||||
return None, str(result)
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching model version info: {e}"
|
||||
logger.error(error_msg)
|
||||
@@ -313,7 +464,7 @@ class CivitaiClient:
|
||||
|
||||
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
||||
"""Fetch image information from Civitai API
|
||||
|
||||
|
||||
Args:
|
||||
image_id: The Civitai image ID
|
||||
|
||||
@@ -321,11 +472,10 @@ class CivitaiClient:
|
||||
Optional[Dict]: The image data or None if not found
|
||||
"""
|
||||
try:
|
||||
downloader = await get_downloader()
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
success, result = await downloader.make_request(
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
@@ -340,7 +490,44 @@ class CivitaiClient:
|
||||
|
||||
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching image info: {e}"
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
"""Fetch all models for a specific Civitai user."""
|
||||
if not username:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/models?username={username}"
|
||||
success, result = await self._make_request(
|
||||
'GET',
|
||||
url,
|
||||
use_auth=True
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error("Failed to fetch models for %s: %s", username, result)
|
||||
return None
|
||||
|
||||
items = result.get("items") if isinstance(result, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
for model in items:
|
||||
versions = model.get("modelVersions")
|
||||
if not isinstance(versions, list):
|
||||
continue
|
||||
for version in versions:
|
||||
self._remove_comfy_metadata(version)
|
||||
|
||||
return items
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Error fetching models for %s: %s", username, exc)
|
||||
return None
|
||||
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from .downloader import DownloadProgress
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,14 +31,40 @@ class DownloadCoordinator:
|
||||
download_id = payload.get("download_id") or self._ws_manager.generate_download_id()
|
||||
payload.setdefault("download_id", download_id)
|
||||
|
||||
async def progress_callback(progress: Any) -> None:
|
||||
async def progress_callback(progress: Any, snapshot: Optional[DownloadProgress] = None) -> None:
|
||||
percent = 0.0
|
||||
metrics: Optional[DownloadProgress] = None
|
||||
|
||||
if isinstance(progress, DownloadProgress):
|
||||
metrics = progress
|
||||
percent = progress.percent_complete
|
||||
elif isinstance(snapshot, DownloadProgress):
|
||||
metrics = snapshot
|
||||
percent = snapshot.percent_complete
|
||||
else:
|
||||
try:
|
||||
percent = float(progress)
|
||||
except (TypeError, ValueError):
|
||||
percent = 0.0
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "progress",
|
||||
"progress": round(percent),
|
||||
"download_id": download_id,
|
||||
}
|
||||
|
||||
if metrics is not None:
|
||||
payload.update(
|
||||
{
|
||||
"bytes_downloaded": metrics.bytes_downloaded,
|
||||
"total_bytes": metrics.total_bytes,
|
||||
"bytes_per_second": metrics.bytes_per_second,
|
||||
}
|
||||
)
|
||||
|
||||
await self._ws_manager.broadcast_download_progress(
|
||||
download_id,
|
||||
{
|
||||
"status": "progress",
|
||||
"progress": progress,
|
||||
"download_id": download_id,
|
||||
},
|
||||
payload,
|
||||
)
|
||||
|
||||
model_id = self._parse_optional_int(payload.get("model_id"), "model_id")
|
||||
@@ -81,6 +109,56 @@ class DownloadCoordinator:
|
||||
|
||||
return result
|
||||
|
||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||
"""Pause an active download and notify listeners."""
|
||||
|
||||
download_manager = await self._download_manager_factory()
|
||||
result = await download_manager.pause_download(download_id)
|
||||
|
||||
if result.get("success"):
|
||||
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "paused",
|
||||
"progress": cached_progress.get("progress", 0),
|
||||
"download_id": download_id,
|
||||
"message": "Download paused by user",
|
||||
}
|
||||
|
||||
for field in ("bytes_downloaded", "total_bytes", "bytes_per_second"):
|
||||
if field in cached_progress:
|
||||
payload[field] = cached_progress[field]
|
||||
|
||||
payload["bytes_per_second"] = 0.0
|
||||
|
||||
await self._ws_manager.broadcast_download_progress(download_id, payload)
|
||||
|
||||
return result
|
||||
|
||||
async def resume_download(self, download_id: str) -> Dict[str, Any]:
|
||||
"""Resume a paused download and notify listeners."""
|
||||
|
||||
download_manager = await self._download_manager_factory()
|
||||
result = await download_manager.resume_download(download_id)
|
||||
|
||||
if result.get("success"):
|
||||
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
|
||||
payload: Dict[str, Any] = {
|
||||
"status": "downloading",
|
||||
"progress": cached_progress.get("progress", 0),
|
||||
"download_id": download_id,
|
||||
"message": "Download resumed by user",
|
||||
}
|
||||
|
||||
for field in ("bytes_downloaded", "total_bytes"):
|
||||
if field in cached_progress:
|
||||
payload[field] = cached_progress[field]
|
||||
|
||||
payload["bytes_per_second"] = cached_progress.get("bytes_per_second", 0.0)
|
||||
|
||||
await self._ws_manager.broadcast_download_progress(download_id, payload)
|
||||
|
||||
return result
|
||||
|
||||
async def list_active_downloads(self) -> Dict[str, Any]:
|
||||
"""Return the active download map from the underlying manager."""
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import logging
|
||||
import os
|
||||
import asyncio
|
||||
import inspect
|
||||
from collections import OrderedDict
|
||||
import uuid
|
||||
from typing import Dict
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES, CIVITAI_MODEL_TAGS
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
from .settings_manager import settings
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_service import get_default_metadata_provider
|
||||
from .downloader import get_downloader
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
|
||||
# Download to temporary file first
|
||||
import tempfile
|
||||
@@ -40,6 +45,7 @@ class DownloadManager:
|
||||
self._active_downloads = OrderedDict() # download_id -> download_info
|
||||
self._download_semaphore = asyncio.Semaphore(5) # Limit concurrent downloads
|
||||
self._download_tasks = {} # download_id -> asyncio.Task
|
||||
self._pause_events: Dict[str, DownloadStreamControl] = {}
|
||||
|
||||
async def _get_lora_scanner(self):
|
||||
"""Get the lora scanner from registry"""
|
||||
@@ -80,13 +86,20 @@ class DownloadManager:
|
||||
'model_id': model_id,
|
||||
'model_version_id': model_version_id,
|
||||
'progress': 0,
|
||||
'status': 'queued'
|
||||
'status': 'queued',
|
||||
'bytes_downloaded': 0,
|
||||
'total_bytes': None,
|
||||
'bytes_per_second': 0.0,
|
||||
'last_progress_timestamp': None,
|
||||
}
|
||||
|
||||
pause_control = DownloadStreamControl()
|
||||
self._pause_events[task_id] = pause_control
|
||||
|
||||
# Create tracking task
|
||||
download_task = asyncio.create_task(
|
||||
self._download_with_semaphore(
|
||||
task_id, model_id, model_version_id, save_dir,
|
||||
task_id, model_id, model_version_id, save_dir,
|
||||
relative_path, progress_callback, use_default_paths, source
|
||||
)
|
||||
)
|
||||
@@ -105,9 +118,10 @@ class DownloadManager:
|
||||
# Clean up task reference
|
||||
if task_id in self._download_tasks:
|
||||
del self._download_tasks[task_id]
|
||||
self._pause_events.pop(task_id, None)
|
||||
|
||||
async def _download_with_semaphore(self, task_id: str, model_id: int, model_version_id: int,
|
||||
save_dir: str, relative_path: str,
|
||||
save_dir: str, relative_path: str,
|
||||
progress_callback=None, use_default_paths: bool = False,
|
||||
source: str = None):
|
||||
"""Execute download with semaphore to limit concurrency"""
|
||||
@@ -117,15 +131,34 @@ class DownloadManager:
|
||||
|
||||
# Wrap progress callback to track progress in active_downloads
|
||||
original_callback = progress_callback
|
||||
async def tracking_callback(progress):
|
||||
async def tracking_callback(progress, metrics=None):
|
||||
progress_value, snapshot = self._normalize_progress(progress, metrics)
|
||||
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]['progress'] = progress
|
||||
info = self._active_downloads[task_id]
|
||||
info['progress'] = round(progress_value)
|
||||
if snapshot is not None:
|
||||
info['bytes_downloaded'] = snapshot.bytes_downloaded
|
||||
info['total_bytes'] = snapshot.total_bytes
|
||||
info['bytes_per_second'] = snapshot.bytes_per_second
|
||||
pause_control = self._pause_events.get(task_id)
|
||||
if isinstance(pause_control, DownloadStreamControl):
|
||||
pause_control.mark_progress(snapshot.timestamp)
|
||||
info['last_progress_timestamp'] = pause_control.last_progress_timestamp
|
||||
|
||||
if original_callback:
|
||||
await original_callback(progress)
|
||||
|
||||
await self._dispatch_progress(original_callback, snapshot, progress_value)
|
||||
|
||||
# Acquire semaphore to limit concurrent downloads
|
||||
try:
|
||||
async with self._download_semaphore:
|
||||
pause_control = self._pause_events.get(task_id)
|
||||
if pause_control is not None and pause_control.is_paused():
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]['status'] = 'paused'
|
||||
self._active_downloads[task_id]['bytes_per_second'] = 0.0
|
||||
await pause_control.wait()
|
||||
|
||||
# Update status to downloading
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]['status'] = 'downloading'
|
||||
@@ -147,12 +180,14 @@ class DownloadManager:
|
||||
self._active_downloads[task_id]['status'] = 'completed' if result['success'] else 'failed'
|
||||
if not result['success']:
|
||||
self._active_downloads[task_id]['error'] = result.get('error', 'Unknown error')
|
||||
self._active_downloads[task_id]['bytes_per_second'] = 0.0
|
||||
|
||||
return result
|
||||
except asyncio.CancelledError:
|
||||
# Handle cancellation
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]['status'] = 'cancelled'
|
||||
self._active_downloads[task_id]['bytes_per_second'] = 0.0
|
||||
logger.info(f"Download cancelled for task {task_id}")
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -161,6 +196,7 @@ class DownloadManager:
|
||||
if task_id in self._active_downloads:
|
||||
self._active_downloads[task_id]['status'] = 'failed'
|
||||
self._active_downloads[task_id]['error'] = str(e)
|
||||
self._active_downloads[task_id]['bytes_per_second'] = 0.0
|
||||
return {'success': False, 'error': str(e)}
|
||||
finally:
|
||||
# Schedule cleanup of download record after delay
|
||||
@@ -172,9 +208,17 @@ class DownloadManager:
|
||||
if task_id in self._active_downloads:
|
||||
del self._active_downloads[task_id]
|
||||
|
||||
async def _execute_original_download(self, model_id, model_version_id, save_dir,
|
||||
relative_path, progress_callback, use_default_paths,
|
||||
download_id=None, source=None):
|
||||
async def _execute_original_download(
|
||||
self,
|
||||
model_id,
|
||||
model_version_id,
|
||||
save_dir,
|
||||
relative_path,
|
||||
progress_callback,
|
||||
use_default_paths,
|
||||
download_id=None,
|
||||
source=None,
|
||||
):
|
||||
"""Wrapper for original download_from_civitai implementation"""
|
||||
try:
|
||||
# Check if model version already exists in library
|
||||
@@ -195,13 +239,8 @@ class DownloadManager:
|
||||
# Check embedding scanner
|
||||
if await embedding_scanner.check_model_version_exists(model_version_id):
|
||||
return {'success': False, 'error': 'Model version already exists in embedding library'}
|
||||
|
||||
# Get metadata provider based on source parameter
|
||||
if source == 'civarchive':
|
||||
from .metadata_service import get_metadata_provider
|
||||
metadata_provider = await get_metadata_provider('civarchive')
|
||||
else:
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
|
||||
# Get version info based on the provided identifier
|
||||
version_info = await metadata_provider.get_model_version(model_id, model_version_id)
|
||||
@@ -241,23 +280,24 @@ class DownloadManager:
|
||||
|
||||
# Handle use_default_paths
|
||||
if use_default_paths:
|
||||
settings_manager = get_settings_manager()
|
||||
# Set save_dir based on model type
|
||||
if model_type == 'checkpoint':
|
||||
default_path = settings.get('default_checkpoint_root')
|
||||
default_path = settings_manager.get('default_checkpoint_root')
|
||||
if not default_path:
|
||||
return {'success': False, 'error': 'Default checkpoint root path not set in settings'}
|
||||
save_dir = default_path
|
||||
elif model_type == 'lora':
|
||||
default_path = settings.get('default_lora_root')
|
||||
default_path = settings_manager.get('default_lora_root')
|
||||
if not default_path:
|
||||
return {'success': False, 'error': 'Default lora root path not set in settings'}
|
||||
save_dir = default_path
|
||||
elif model_type == 'embedding':
|
||||
default_path = settings.get('default_embedding_root')
|
||||
default_path = settings_manager.get('default_embedding_root')
|
||||
if not default_path:
|
||||
return {'success': False, 'error': 'Default embedding root path not set in settings'}
|
||||
save_dir = default_path
|
||||
|
||||
|
||||
# Calculate relative path using template
|
||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||
|
||||
@@ -291,11 +331,22 @@ class DownloadManager:
|
||||
await progress_callback(0)
|
||||
|
||||
# 2. Get file information
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
|
||||
file_info = next((f for f in version_info.get('files', []) if f.get('primary') and f.get('type') == 'Model'), None)
|
||||
if not file_info:
|
||||
return {'success': False, 'error': 'No primary file found in metadata'}
|
||||
if not file_info.get('downloadUrl'):
|
||||
return {'success': False, 'error': 'No download URL found for primary file'}
|
||||
mirrors = file_info.get('mirrors') or []
|
||||
download_urls = []
|
||||
if mirrors:
|
||||
for mirror in mirrors:
|
||||
if mirror.get('deletedAt') is None and mirror.get('url'):
|
||||
download_urls.append(mirror['url'])
|
||||
else:
|
||||
download_url = file_info.get('downloadUrl')
|
||||
if download_url:
|
||||
download_urls.append(download_url)
|
||||
|
||||
if not download_urls:
|
||||
return {'success': False, 'error': 'No mirror URL found'}
|
||||
|
||||
# 3. Prepare download
|
||||
file_name = file_info['name']
|
||||
@@ -314,16 +365,29 @@ class DownloadManager:
|
||||
|
||||
# 6. Start download process
|
||||
result = await self._execute_download(
|
||||
download_url=file_info.get('downloadUrl', ''),
|
||||
download_urls=download_urls,
|
||||
save_dir=save_dir,
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path=relative_path,
|
||||
progress_callback=progress_callback,
|
||||
model_type=model_type,
|
||||
download_id=download_id
|
||||
download_id=download_id,
|
||||
)
|
||||
|
||||
if result.get('success', False):
|
||||
resolved_model_id = (
|
||||
model_id
|
||||
or version_info.get('modelId')
|
||||
or (version_info.get('model') or {}).get('id')
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
version_info,
|
||||
model_version_id,
|
||||
)
|
||||
|
||||
# If early_access_msg exists and download failed, replace error message
|
||||
if 'early_access_msg' in locals() and not result.get('success', False):
|
||||
result['error'] = early_access_msg
|
||||
@@ -338,6 +402,96 @@ class DownloadManager:
|
||||
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def _sync_downloaded_version(
|
||||
self,
|
||||
model_type: str,
|
||||
model_id_value,
|
||||
version_info: Dict,
|
||||
fallback_version_id=None,
|
||||
) -> None:
|
||||
"""Ensure update tracking reflects a newly downloaded version."""
|
||||
|
||||
try:
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
except Exception as exc:
|
||||
logger.debug("Skipping update sync; failed to acquire update service: %s", exc)
|
||||
return
|
||||
|
||||
if update_service is None:
|
||||
return
|
||||
|
||||
resolved_model_id = model_id_value
|
||||
if resolved_model_id is None:
|
||||
resolved_model_id = version_info.get('modelId')
|
||||
if resolved_model_id is None:
|
||||
model_info = version_info.get('model')
|
||||
if isinstance(model_info, dict):
|
||||
resolved_model_id = model_info.get('id')
|
||||
try:
|
||||
resolved_model_id = int(resolved_model_id)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug("Skipping update sync; invalid model id: %s", resolved_model_id)
|
||||
return
|
||||
|
||||
version_id = version_info.get('id')
|
||||
if version_id is None:
|
||||
version_id = fallback_version_id
|
||||
try:
|
||||
version_id = int(version_id)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
"Skipping update sync; invalid version id for model %s: %s",
|
||||
resolved_model_id,
|
||||
version_id,
|
||||
)
|
||||
return
|
||||
|
||||
version_ids = set()
|
||||
scanner = None
|
||||
try:
|
||||
if model_type == 'lora':
|
||||
scanner = await self._get_lora_scanner()
|
||||
elif model_type == 'checkpoint':
|
||||
scanner = await self._get_checkpoint_scanner()
|
||||
elif model_type == 'embedding':
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||
|
||||
if scanner is not None:
|
||||
try:
|
||||
local_versions = await scanner.get_model_versions_by_id(resolved_model_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to collect local versions for %s model %s: %s",
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
for entry in local_versions or []:
|
||||
vid = entry.get('versionId')
|
||||
try:
|
||||
version_ids.add(int(vid))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
version_ids.add(version_id)
|
||||
|
||||
try:
|
||||
await update_service.update_in_library_versions(
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
sorted(version_ids),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to update in-library versions for %s model %s: %s",
|
||||
model_type,
|
||||
resolved_model_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
def _calculate_relative_path(self, version_info: Dict, model_type: str = 'lora') -> str:
|
||||
"""Calculate relative path using template from settings
|
||||
|
||||
@@ -349,7 +503,8 @@ class DownloadManager:
|
||||
Relative path string
|
||||
"""
|
||||
# Get path template from settings for specific model type
|
||||
path_template = settings.get_download_path_template(model_type)
|
||||
settings_manager = get_settings_manager()
|
||||
path_template = settings_manager.get_download_path_template(model_type)
|
||||
|
||||
# If template is empty, return empty path (flat structure)
|
||||
if not path_template:
|
||||
@@ -366,35 +521,40 @@ class DownloadManager:
|
||||
author = 'Anonymous'
|
||||
|
||||
# Apply mapping if available
|
||||
base_model_mappings = settings.get('base_model_path_mappings', {})
|
||||
base_model_mappings = settings_manager.get('base_model_path_mappings', {})
|
||||
mapped_base_model = base_model_mappings.get(base_model, base_model)
|
||||
|
||||
model_info = version_info.get('model') or {}
|
||||
|
||||
# Get model tags
|
||||
model_tags = version_info.get('model', {}).get('tags', [])
|
||||
|
||||
# Find the first Civitai model tag that exists in model_tags
|
||||
first_tag = ''
|
||||
for civitai_tag in CIVITAI_MODEL_TAGS:
|
||||
if civitai_tag in model_tags:
|
||||
first_tag = civitai_tag
|
||||
break
|
||||
|
||||
# If no Civitai model tag found, fallback to first tag
|
||||
if not first_tag and model_tags:
|
||||
first_tag = model_tags[0]
|
||||
|
||||
model_tags = model_info.get('tags', [])
|
||||
|
||||
first_tag = settings_manager.resolve_priority_tag_for_model(model_tags, model_type)
|
||||
|
||||
# Format the template with available data
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
|
||||
formatted_path = formatted_path.replace('{first_tag}', first_tag)
|
||||
formatted_path = formatted_path.replace('{author}', author)
|
||||
|
||||
formatted_path = formatted_path.replace('{model_name}', sanitize_folder_name(model_info.get('name', '')))
|
||||
formatted_path = formatted_path.replace('{version_name}', sanitize_folder_name(version_info.get('name', '')))
|
||||
|
||||
if model_type == 'embedding':
|
||||
formatted_path = formatted_path.replace(' ', '_')
|
||||
|
||||
return formatted_path
|
||||
|
||||
async def _execute_download(self, download_url: str, save_dir: str,
|
||||
metadata, version_info: Dict,
|
||||
relative_path: str, progress_callback=None,
|
||||
model_type: str = "lora", download_id: str = None) -> Dict:
|
||||
async def _execute_download(
|
||||
self,
|
||||
download_urls: List[str],
|
||||
save_dir: str,
|
||||
metadata,
|
||||
version_info: Dict,
|
||||
relative_path: str,
|
||||
progress_callback=None,
|
||||
model_type: str = "lora",
|
||||
download_id: str = None,
|
||||
) -> Dict:
|
||||
"""Execute the actual download process including preview images and model files"""
|
||||
try:
|
||||
# Extract original filename details
|
||||
@@ -425,7 +585,9 @@ class DownloadManager:
|
||||
|
||||
part_path = save_path + '.part'
|
||||
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
|
||||
|
||||
|
||||
pause_control = self._pause_events.get(download_id) if download_id else None
|
||||
|
||||
# Store file paths in active_downloads for potential cleanup
|
||||
if download_id and download_id in self._active_downloads:
|
||||
self._active_downloads[download_id]['file_path'] = save_path
|
||||
@@ -434,110 +596,178 @@ class DownloadManager:
|
||||
# Download preview image if available
|
||||
images = version_info.get('images', [])
|
||||
if images:
|
||||
# Report preview download progress
|
||||
if progress_callback:
|
||||
await progress_callback(1) # 1% progress for starting preview download
|
||||
|
||||
# Check if it's a video or an image
|
||||
is_video = images[0].get('type') == 'video'
|
||||
|
||||
if (is_video):
|
||||
# For videos, use .mp4 extension
|
||||
preview_ext = '.mp4'
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
|
||||
# Download video directly using downloader
|
||||
downloader = await get_downloader()
|
||||
success, result = await downloader.download_file(
|
||||
images[0]['url'],
|
||||
preview_path,
|
||||
use_auth=False # Preview images typically don't need auth
|
||||
)
|
||||
if success:
|
||||
metadata.preview_url = preview_path.replace(os.sep, '/')
|
||||
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
|
||||
else:
|
||||
# For images, use WebP format for better performance
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
# Download the original image to temp path using downloader
|
||||
downloader = await get_downloader()
|
||||
success, content, headers = await downloader.download_to_memory(
|
||||
images[0]['url'],
|
||||
use_auth=False
|
||||
)
|
||||
if success:
|
||||
# Save to temp file
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(content)
|
||||
# Optimize and convert to WebP
|
||||
preview_path = os.path.splitext(save_path)[0] + '.webp'
|
||||
|
||||
# Use ExifUtils to optimize and convert the image
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
image_data=temp_path,
|
||||
target_width=CARD_PREVIEW_WIDTH,
|
||||
format='webp',
|
||||
quality=85,
|
||||
preserve_metadata=False
|
||||
)
|
||||
|
||||
# Save the optimized image
|
||||
with open(preview_path, 'wb') as f:
|
||||
f.write(optimized_data)
|
||||
|
||||
# Update metadata
|
||||
metadata.preview_url = preview_path.replace(os.sep, '/')
|
||||
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
|
||||
|
||||
# Remove temporary file
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete temp file: {e}")
|
||||
settings_manager = get_settings_manager()
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get('blur_mature_content', True)
|
||||
)
|
||||
selected_image, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
)
|
||||
|
||||
preview_url = selected_image.get('url') if selected_image else None
|
||||
media_type = (
|
||||
(selected_image.get('type') or '').lower()
|
||||
if selected_image
|
||||
else ''
|
||||
)
|
||||
|
||||
def _extension_from_url(url: str, fallback: str) -> str:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return fallback
|
||||
ext = os.path.splitext(parsed.path)[1]
|
||||
return ext or fallback
|
||||
|
||||
preview_downloaded = False
|
||||
preview_path = None
|
||||
|
||||
if preview_url:
|
||||
downloader = await get_downloader()
|
||||
|
||||
if media_type == 'video':
|
||||
preview_ext = _extension_from_url(preview_url, '.mp4')
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type='video')
|
||||
attempt_urls: List[str] = []
|
||||
if rewritten:
|
||||
attempt_urls.append(rewritten_url)
|
||||
attempt_urls.append(preview_url)
|
||||
|
||||
seen_attempts = set()
|
||||
for attempt in attempt_urls:
|
||||
if not attempt or attempt in seen_attempts:
|
||||
continue
|
||||
seen_attempts.add(attempt)
|
||||
success, _ = await downloader.download_file(
|
||||
attempt,
|
||||
preview_path,
|
||||
use_auth=False
|
||||
)
|
||||
if success:
|
||||
preview_downloaded = True
|
||||
break
|
||||
else:
|
||||
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type='image')
|
||||
if rewritten:
|
||||
preview_ext = _extension_from_url(preview_url, '.png')
|
||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||
success, _ = await downloader.download_file(
|
||||
rewritten_url,
|
||||
preview_path,
|
||||
use_auth=False
|
||||
)
|
||||
if success:
|
||||
preview_downloaded = True
|
||||
|
||||
if not preview_downloaded:
|
||||
temp_path: str | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
|
||||
temp_path = temp_file.name
|
||||
|
||||
success, content, _ = await downloader.download_to_memory(
|
||||
preview_url,
|
||||
use_auth=False
|
||||
)
|
||||
if success:
|
||||
with open(temp_path, 'wb') as temp_file_handle:
|
||||
temp_file_handle.write(content)
|
||||
preview_path = os.path.splitext(save_path)[0] + '.webp'
|
||||
|
||||
optimized_data, _ = ExifUtils.optimize_image(
|
||||
image_data=temp_path,
|
||||
target_width=CARD_PREVIEW_WIDTH,
|
||||
format='webp',
|
||||
quality=85,
|
||||
preserve_metadata=False
|
||||
)
|
||||
|
||||
with open(preview_path, 'wb') as preview_file:
|
||||
preview_file.write(optimized_data)
|
||||
|
||||
preview_downloaded = True
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete temp file: {e}")
|
||||
|
||||
if preview_downloaded and preview_path:
|
||||
metadata.preview_url = preview_path.replace(os.sep, '/')
|
||||
metadata.preview_nsfw_level = nsfw_level
|
||||
if download_id and download_id in self._active_downloads:
|
||||
self._active_downloads[download_id]['preview_path'] = preview_path
|
||||
|
||||
# Report preview download completion
|
||||
if progress_callback:
|
||||
await progress_callback(3) # 3% progress after preview download
|
||||
|
||||
# Download model file with progress tracking using downloader
|
||||
downloader = await get_downloader()
|
||||
# Determine if the download URL is from Civitai
|
||||
use_auth = download_url.startswith("https://civitai.com/api/download/")
|
||||
success, result = await downloader.download_file(
|
||||
download_url,
|
||||
save_path, # Use full path instead of separate dir and filename
|
||||
progress_callback=lambda p: self._handle_download_progress(p, progress_callback),
|
||||
use_auth=use_auth # Only use authentication for Civitai downloads
|
||||
)
|
||||
if pause_control is not None:
|
||||
pause_control.update_stall_timeout(downloader.stall_timeout)
|
||||
last_error = None
|
||||
for download_url in download_urls:
|
||||
use_auth = download_url.startswith("https://civitai.com/api/download/")
|
||||
download_kwargs = {
|
||||
"progress_callback": lambda progress, snapshot=None: self._handle_download_progress(
|
||||
progress,
|
||||
progress_callback,
|
||||
snapshot,
|
||||
),
|
||||
"use_auth": use_auth, # Only use authentication for Civitai downloads
|
||||
}
|
||||
|
||||
if not success:
|
||||
if pause_control is not None:
|
||||
download_kwargs["pause_event"] = pause_control
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
download_url,
|
||||
save_path, # Use full path instead of separate dir and filename
|
||||
**download_kwargs,
|
||||
)
|
||||
|
||||
if success:
|
||||
break
|
||||
|
||||
last_error = result
|
||||
if os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove incomplete file {save_path}: {e}")
|
||||
else:
|
||||
# Clean up files on failure, but preserve .part file for resume
|
||||
cleanup_files = [metadata_path]
|
||||
if metadata.preview_url and os.path.exists(metadata.preview_url):
|
||||
cleanup_files.append(metadata.preview_url)
|
||||
|
||||
preview_path_value = getattr(metadata, 'preview_url', None)
|
||||
if preview_path_value and os.path.exists(preview_path_value):
|
||||
cleanup_files.append(preview_path_value)
|
||||
|
||||
for path in cleanup_files:
|
||||
if path and os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup file {path}: {e}")
|
||||
|
||||
|
||||
# Log but don't remove .part file to allow resume
|
||||
if os.path.exists(part_path):
|
||||
logger.info(f"Preserving partial download for resume: {part_path}")
|
||||
|
||||
return {'success': False, 'error': result}
|
||||
|
||||
return {'success': False, 'error': last_error or 'Failed to download file'}
|
||||
|
||||
# 4. Update file information (size and modified time)
|
||||
metadata.update_file_info(save_path)
|
||||
|
||||
# 5. Final metadata update
|
||||
await MetadataManager.save_metadata(save_path, metadata)
|
||||
scanner = None
|
||||
adjust_root: Optional[str] = None
|
||||
|
||||
# 6. Update cache based on model type
|
||||
# 5. Determine scanner and adjust metadata for cache consistency
|
||||
if model_type == "checkpoint":
|
||||
scanner = await self._get_checkpoint_scanner()
|
||||
logger.info(f"Updating checkpoint cache for {save_path}")
|
||||
@@ -547,9 +777,33 @@ class DownloadManager:
|
||||
elif model_type == "embedding":
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
logger.info(f"Updating embedding cache for {save_path}")
|
||||
|
||||
|
||||
if scanner is not None:
|
||||
file_path_for_adjust = getattr(metadata, "file_path", save_path)
|
||||
if isinstance(file_path_for_adjust, str):
|
||||
normalized_file_path = file_path_for_adjust.replace(os.sep, "/")
|
||||
else:
|
||||
normalized_file_path = str(file_path_for_adjust)
|
||||
|
||||
find_root = getattr(scanner, "_find_root_for_file", None)
|
||||
if callable(find_root):
|
||||
try:
|
||||
adjust_root = find_root(normalized_file_path)
|
||||
except TypeError:
|
||||
adjust_root = None
|
||||
|
||||
adjust_metadata = getattr(scanner, "adjust_metadata", None)
|
||||
if callable(adjust_metadata):
|
||||
metadata = adjust_metadata(metadata, normalized_file_path, adjust_root)
|
||||
|
||||
# 6. Persist metadata with any adjustments
|
||||
await MetadataManager.save_metadata(save_path, metadata)
|
||||
|
||||
# Convert metadata to dictionary
|
||||
metadata_dict = metadata.to_dict()
|
||||
adjust_cached_entry = getattr(scanner, "adjust_cached_entry", None) if scanner is not None else None
|
||||
if callable(adjust_cached_entry):
|
||||
metadata_dict = adjust_cached_entry(metadata_dict)
|
||||
|
||||
# Add model to cache and save to disk in a single operation
|
||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||
@@ -578,21 +832,37 @@ class DownloadManager:
|
||||
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def _handle_download_progress(self, file_progress: float, progress_callback):
|
||||
"""Convert file download progress to overall progress
|
||||
|
||||
Args:
|
||||
file_progress: Progress of file download (0-100)
|
||||
progress_callback: Callback function for progress updates
|
||||
"""
|
||||
if progress_callback:
|
||||
# Scale file progress to 3-100 range (after preview download)
|
||||
overall_progress = 3 + (file_progress * 0.97) # 97% of progress for file download
|
||||
await progress_callback(round(overall_progress))
|
||||
async def _handle_download_progress(
|
||||
self,
|
||||
progress_update,
|
||||
progress_callback,
|
||||
snapshot=None,
|
||||
):
|
||||
"""Convert file download progress to overall progress."""
|
||||
|
||||
if not progress_callback:
|
||||
return
|
||||
|
||||
file_progress, original_snapshot = self._normalize_progress(progress_update, snapshot)
|
||||
overall_progress = 3 + (file_progress * 0.97)
|
||||
overall_progress = max(0.0, min(overall_progress, 100.0))
|
||||
rounded_progress = round(overall_progress)
|
||||
|
||||
normalized_snapshot: Optional[DownloadProgress] = None
|
||||
if original_snapshot is not None:
|
||||
normalized_snapshot = DownloadProgress(
|
||||
percent_complete=overall_progress,
|
||||
bytes_downloaded=original_snapshot.bytes_downloaded,
|
||||
total_bytes=original_snapshot.total_bytes,
|
||||
bytes_per_second=original_snapshot.bytes_per_second,
|
||||
timestamp=original_snapshot.timestamp,
|
||||
)
|
||||
|
||||
await self._dispatch_progress(progress_callback, normalized_snapshot, rounded_progress)
|
||||
|
||||
async def cancel_download(self, download_id: str) -> Dict:
|
||||
"""Cancel an active download by download_id
|
||||
|
||||
|
||||
Args:
|
||||
download_id: The unique identifier of the download task
|
||||
|
||||
@@ -606,10 +876,15 @@ class DownloadManager:
|
||||
# Get the task and cancel it
|
||||
task = self._download_tasks[download_id]
|
||||
task.cancel()
|
||||
|
||||
|
||||
pause_control = self._pause_events.get(download_id)
|
||||
if pause_control is not None:
|
||||
pause_control.resume()
|
||||
|
||||
# Update status in active downloads
|
||||
if download_id in self._active_downloads:
|
||||
self._active_downloads[download_id]['status'] = 'cancelling'
|
||||
self._active_downloads[download_id]['bytes_per_second'] = 0.0
|
||||
|
||||
# Wait briefly for the task to acknowledge cancellation
|
||||
try:
|
||||
@@ -650,7 +925,15 @@ class DownloadManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting metadata file: {e}")
|
||||
|
||||
# Delete preview file if exists (.webp or .mp4)
|
||||
preview_path_value = download_info.get('preview_path')
|
||||
if preview_path_value and os.path.exists(preview_path_value):
|
||||
try:
|
||||
os.unlink(preview_path_value)
|
||||
logger.debug(f"Deleted preview file: {preview_path_value}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting preview file: {e}")
|
||||
|
||||
# Delete preview file if exists (.webp or .mp4) for legacy paths
|
||||
for preview_ext in ['.webp', '.mp4']:
|
||||
preview_path = os.path.splitext(file_path)[0] + preview_ext
|
||||
if os.path.exists(preview_path):
|
||||
@@ -664,7 +947,109 @@ class DownloadManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error cancelling download: {e}", exc_info=True)
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
finally:
|
||||
self._pause_events.pop(download_id, None)
|
||||
|
||||
async def pause_download(self, download_id: str) -> Dict:
|
||||
"""Pause an active download without losing progress."""
|
||||
|
||||
if download_id not in self._download_tasks:
|
||||
return {'success': False, 'error': 'Download task not found'}
|
||||
|
||||
pause_control = self._pause_events.get(download_id)
|
||||
if pause_control is None:
|
||||
return {'success': False, 'error': 'Download task not found'}
|
||||
|
||||
if pause_control.is_paused():
|
||||
return {'success': False, 'error': 'Download is already paused'}
|
||||
|
||||
pause_control.pause()
|
||||
|
||||
download_info = self._active_downloads.get(download_id)
|
||||
if download_info is not None:
|
||||
download_info['status'] = 'paused'
|
||||
download_info['bytes_per_second'] = 0.0
|
||||
|
||||
return {'success': True, 'message': 'Download paused successfully'}
|
||||
|
||||
async def resume_download(self, download_id: str) -> Dict:
|
||||
"""Resume a previously paused download."""
|
||||
|
||||
pause_control = self._pause_events.get(download_id)
|
||||
if pause_control is None:
|
||||
return {'success': False, 'error': 'Download task not found'}
|
||||
|
||||
if pause_control.is_set():
|
||||
return {'success': False, 'error': 'Download is not paused'}
|
||||
|
||||
download_info = self._active_downloads.get(download_id)
|
||||
force_reconnect = False
|
||||
if pause_control is not None:
|
||||
elapsed = pause_control.time_since_last_progress()
|
||||
threshold = max(30.0, pause_control.stall_timeout / 2.0)
|
||||
if elapsed is not None and elapsed >= threshold:
|
||||
force_reconnect = True
|
||||
logger.info(
|
||||
"Forcing reconnect for download %s after %.1f seconds without progress",
|
||||
download_id,
|
||||
elapsed,
|
||||
)
|
||||
|
||||
pause_control.resume(force_reconnect=force_reconnect)
|
||||
|
||||
if download_info is not None:
|
||||
if download_info.get('status') == 'paused':
|
||||
download_info['status'] = 'downloading'
|
||||
download_info.setdefault('bytes_per_second', 0.0)
|
||||
|
||||
return {'success': True, 'message': 'Download resumed successfully'}
|
||||
|
||||
@staticmethod
|
||||
def _coerce_progress_value(progress) -> float:
|
||||
try:
|
||||
return float(progress)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@classmethod
|
||||
def _normalize_progress(
|
||||
cls,
|
||||
progress,
|
||||
snapshot: Optional[DownloadProgress] = None,
|
||||
) -> Tuple[float, Optional[DownloadProgress]]:
|
||||
if isinstance(progress, DownloadProgress):
|
||||
return progress.percent_complete, progress
|
||||
|
||||
if isinstance(snapshot, DownloadProgress):
|
||||
return snapshot.percent_complete, snapshot
|
||||
|
||||
if isinstance(progress, dict):
|
||||
if 'percent_complete' in progress:
|
||||
return cls._coerce_progress_value(progress['percent_complete']), snapshot
|
||||
if 'progress' in progress:
|
||||
return cls._coerce_progress_value(progress['progress']), snapshot
|
||||
|
||||
return cls._coerce_progress_value(progress), None
|
||||
|
||||
async def _dispatch_progress(
|
||||
self,
|
||||
callback,
|
||||
snapshot: Optional[DownloadProgress],
|
||||
progress_value: float,
|
||||
) -> None:
|
||||
try:
|
||||
if snapshot is not None:
|
||||
result = callback(snapshot, snapshot)
|
||||
else:
|
||||
result = callback(progress_value)
|
||||
except TypeError:
|
||||
result = callback(progress_value)
|
||||
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
elif asyncio.iscoroutine(result):
|
||||
await result
|
||||
|
||||
async def get_active_downloads(self) -> Dict:
|
||||
"""Get information about all active downloads
|
||||
|
||||
@@ -679,8 +1064,11 @@ class DownloadManager:
|
||||
'model_version_id': info.get('model_version_id'),
|
||||
'progress': info.get('progress', 0),
|
||||
'status': info.get('status', 'unknown'),
|
||||
'error': info.get('error', None)
|
||||
'error': info.get('error', None),
|
||||
'bytes_downloaded': info.get('bytes_downloaded', 0),
|
||||
'total_bytes': info.get('total_bytes'),
|
||||
'bytes_per_second': info.get('bytes_per_second', 0.0),
|
||||
}
|
||||
for task_id, info in self._active_downloads.items()
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,95 @@ import os
|
||||
import logging
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Tuple, Callable, Union
|
||||
from ..services.settings_manager import settings
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from .errors import RateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadProgress:
|
||||
"""Snapshot of a download transfer at a moment in time."""
|
||||
|
||||
percent_complete: float
|
||||
bytes_downloaded: int
|
||||
total_bytes: Optional[int]
|
||||
bytes_per_second: float
|
||||
timestamp: float
|
||||
|
||||
|
||||
class DownloadStreamControl:
|
||||
"""Synchronize pause/resume requests and reconnect hints for a download."""
|
||||
|
||||
def __init__(self, *, stall_timeout: Optional[float] = None) -> None:
|
||||
self._event = asyncio.Event()
|
||||
self._event.set()
|
||||
self._reconnect_requested = False
|
||||
self.last_progress_timestamp: Optional[float] = None
|
||||
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
return not self._event.is_set()
|
||||
|
||||
def set(self) -> None:
|
||||
self._event.set()
|
||||
|
||||
def clear(self) -> None:
|
||||
self._event.clear()
|
||||
|
||||
async def wait(self) -> None:
|
||||
await self._event.wait()
|
||||
|
||||
def pause(self) -> None:
|
||||
self.clear()
|
||||
|
||||
def resume(self, *, force_reconnect: bool = False) -> None:
|
||||
if force_reconnect:
|
||||
self._reconnect_requested = True
|
||||
self.set()
|
||||
|
||||
def request_reconnect(self) -> None:
|
||||
self._reconnect_requested = True
|
||||
self.set()
|
||||
|
||||
def has_reconnect_request(self) -> bool:
|
||||
return self._reconnect_requested
|
||||
|
||||
def consume_reconnect_request(self) -> bool:
|
||||
reconnect = self._reconnect_requested
|
||||
self._reconnect_requested = False
|
||||
return reconnect
|
||||
|
||||
def mark_progress(self, timestamp: Optional[float] = None) -> None:
|
||||
self.last_progress_timestamp = timestamp or datetime.now().timestamp()
|
||||
self._reconnect_requested = False
|
||||
|
||||
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]:
|
||||
if self.last_progress_timestamp is None:
|
||||
return None
|
||||
reference = now if now is not None else datetime.now().timestamp()
|
||||
return max(0.0, reference - self.last_progress_timestamp)
|
||||
|
||||
def update_stall_timeout(self, stall_timeout: float) -> None:
|
||||
self.stall_timeout = float(stall_timeout)
|
||||
|
||||
|
||||
class DownloadRestartRequested(Exception):
|
||||
"""Raised when a caller explicitly requests a fresh HTTP stream."""
|
||||
|
||||
|
||||
class DownloadStalledError(Exception):
|
||||
"""Raised when download progress stalls beyond the configured timeout."""
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
|
||||
|
||||
@@ -52,10 +134,14 @@ class Downloader:
|
||||
self.max_retries = 5
|
||||
self.base_delay = 2.0 # Base delay for exponential backoff
|
||||
self.session_timeout = 300 # 5 minutes
|
||||
self.stall_timeout = self._resolve_stall_timeout()
|
||||
|
||||
# Default headers
|
||||
self.default_headers = {
|
||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0'
|
||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
|
||||
# Explicitly request uncompressed payloads so aiohttp doesn't need optional
|
||||
# decoders (e.g. zstandard) that may be missing in runtime environments.
|
||||
'Accept-Encoding': 'identity',
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -64,14 +150,38 @@ class Downloader:
|
||||
if self._session is None or self._should_refresh_session():
|
||||
await self._create_session()
|
||||
return self._session
|
||||
|
||||
|
||||
@property
|
||||
def proxy_url(self) -> Optional[str]:
|
||||
"""Get the current proxy URL (initialize if needed)"""
|
||||
if not hasattr(self, '_proxy_url'):
|
||||
self._proxy_url = None
|
||||
return self._proxy_url
|
||||
|
||||
|
||||
def _resolve_stall_timeout(self) -> float:
|
||||
"""Determine the stall timeout from settings or environment."""
|
||||
default_timeout = 120.0
|
||||
settings_timeout = None
|
||||
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
settings_timeout = settings_manager.get('download_stall_timeout_seconds')
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("Failed to read stall timeout from settings: %s", exc)
|
||||
|
||||
raw_value = (
|
||||
settings_timeout
|
||||
if settings_timeout not in (None, "")
|
||||
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT')
|
||||
)
|
||||
|
||||
try:
|
||||
timeout_value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
timeout_value = default_timeout
|
||||
|
||||
return max(30.0, timeout_value)
|
||||
|
||||
def _should_refresh_session(self) -> bool:
|
||||
"""Check if session should be refreshed"""
|
||||
if self._session is None:
|
||||
@@ -94,12 +204,13 @@ class Downloader:
|
||||
|
||||
# Check for app-level proxy settings
|
||||
proxy_url = None
|
||||
if settings.get('proxy_enabled', False):
|
||||
proxy_host = settings.get('proxy_host', '').strip()
|
||||
proxy_port = settings.get('proxy_port', '').strip()
|
||||
proxy_type = settings.get('proxy_type', 'http').lower()
|
||||
proxy_username = settings.get('proxy_username', '').strip()
|
||||
proxy_password = settings.get('proxy_password', '').strip()
|
||||
settings_manager = get_settings_manager()
|
||||
if settings_manager.get('proxy_enabled', False):
|
||||
proxy_host = settings_manager.get('proxy_host', '').strip()
|
||||
proxy_port = settings_manager.get('proxy_port', '').strip()
|
||||
proxy_type = settings_manager.get('proxy_type', 'http').lower()
|
||||
proxy_username = settings_manager.get('proxy_username', '').strip()
|
||||
proxy_password = settings_manager.get('proxy_password', '').strip()
|
||||
|
||||
if proxy_host and proxy_port:
|
||||
# Build proxy URL
|
||||
@@ -146,7 +257,8 @@ class Downloader:
|
||||
|
||||
if use_auth:
|
||||
# Add CivitAI API key if available
|
||||
api_key = settings.get('civitai_api_key')
|
||||
settings_manager = get_settings_manager()
|
||||
api_key = settings_manager.get('civitai_api_key')
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
headers['Content-Type'] = 'application/json'
|
||||
@@ -157,10 +269,11 @@ class Downloader:
|
||||
self,
|
||||
url: str,
|
||||
save_path: str,
|
||||
progress_callback: Optional[Callable[[float], None]] = None,
|
||||
progress_callback: Optional[Callable[..., Awaitable[None]]] = None,
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
allow_resume: bool = True
|
||||
allow_resume: bool = True,
|
||||
pause_event: Optional[DownloadStreamControl] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Download a file with resumable downloads and retry mechanism
|
||||
@@ -172,6 +285,7 @@ class Downloader:
|
||||
use_auth: Whether to include authentication headers (e.g., CivitAI API key)
|
||||
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
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, save_path or error message)
|
||||
@@ -246,7 +360,16 @@ class Downloader:
|
||||
if allow_resume:
|
||||
os.rename(part_path, save_path)
|
||||
if progress_callback:
|
||||
await progress_callback(100)
|
||||
await self._dispatch_progress_callback(
|
||||
progress_callback,
|
||||
DownloadProgress(
|
||||
percent_complete=100.0,
|
||||
bytes_downloaded=part_size,
|
||||
total_bytes=actual_size,
|
||||
bytes_per_second=0.0,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
),
|
||||
)
|
||||
return True, save_path
|
||||
# Remove corrupted part file and restart
|
||||
os.remove(part_path)
|
||||
@@ -274,36 +397,146 @@ class Downloader:
|
||||
|
||||
current_size = resume_offset
|
||||
last_progress_report_time = datetime.now()
|
||||
|
||||
progress_samples: deque[tuple[datetime, int]] = deque()
|
||||
progress_samples.append((last_progress_report_time, current_size))
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
|
||||
|
||||
# Stream download to file with progress updates
|
||||
loop = asyncio.get_running_loop()
|
||||
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb'
|
||||
control = pause_event
|
||||
|
||||
if control is not None:
|
||||
control.update_stall_timeout(self.stall_timeout)
|
||||
|
||||
with open(part_path, mode) as f:
|
||||
async for chunk in response.content.iter_chunked(self.chunk_size):
|
||||
if chunk:
|
||||
# Run blocking file write in executor
|
||||
await loop.run_in_executor(None, f.write, chunk)
|
||||
current_size += len(chunk)
|
||||
|
||||
# Limit progress update frequency to reduce overhead
|
||||
now = datetime.now()
|
||||
time_diff = (now - last_progress_report_time).total_seconds()
|
||||
|
||||
if progress_callback and total_size and time_diff >= 1.0:
|
||||
progress = (current_size / total_size) * 100
|
||||
await progress_callback(progress)
|
||||
last_progress_report_time = now
|
||||
while True:
|
||||
active_stall_timeout = control.stall_timeout if control else self.stall_timeout
|
||||
|
||||
if control is not None:
|
||||
if control.is_paused():
|
||||
await control.wait()
|
||||
resume_time = datetime.now()
|
||||
last_progress_report_time = resume_time
|
||||
if control.consume_reconnect_request():
|
||||
raise DownloadRestartRequested(
|
||||
"Reconnect requested after resume"
|
||||
)
|
||||
elif control.consume_reconnect_request():
|
||||
raise DownloadRestartRequested("Reconnect requested")
|
||||
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
response.content.read(self.chunk_size),
|
||||
timeout=active_stall_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
logger.warning(
|
||||
"Download stalled for %.1f seconds without progress from %s",
|
||||
active_stall_timeout,
|
||||
url,
|
||||
)
|
||||
raise DownloadStalledError(
|
||||
f"No data received for {active_stall_timeout:.1f} seconds"
|
||||
) from exc
|
||||
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
# Run blocking file write in executor
|
||||
await loop.run_in_executor(None, f.write, chunk)
|
||||
current_size += len(chunk)
|
||||
|
||||
now = datetime.now()
|
||||
if control is not None:
|
||||
control.mark_progress(timestamp=now.timestamp())
|
||||
|
||||
# Limit progress update frequency to reduce overhead
|
||||
time_diff = (now - last_progress_report_time).total_seconds()
|
||||
|
||||
if progress_callback and time_diff >= 1.0:
|
||||
progress_samples.append((now, current_size))
|
||||
cutoff = now - timedelta(seconds=5)
|
||||
while progress_samples and progress_samples[0][0] < cutoff:
|
||||
progress_samples.popleft()
|
||||
|
||||
percent = (current_size / total_size) * 100 if total_size else 0.0
|
||||
bytes_per_second = 0.0
|
||||
if len(progress_samples) >= 2:
|
||||
first_time, first_bytes = progress_samples[0]
|
||||
last_time, last_bytes = progress_samples[-1]
|
||||
elapsed = (last_time - first_time).total_seconds()
|
||||
if elapsed > 0:
|
||||
bytes_per_second = (last_bytes - first_bytes) / elapsed
|
||||
|
||||
progress_snapshot = DownloadProgress(
|
||||
percent_complete=percent,
|
||||
bytes_downloaded=current_size,
|
||||
total_bytes=total_size or None,
|
||||
bytes_per_second=bytes_per_second,
|
||||
timestamp=now.timestamp(),
|
||||
)
|
||||
|
||||
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
|
||||
last_progress_report_time = now
|
||||
|
||||
# Download completed successfully
|
||||
# Verify file size if total_size was provided
|
||||
final_size = os.path.getsize(part_path)
|
||||
if total_size > 0 and final_size != total_size:
|
||||
logger.warning(f"File size mismatch. Expected: {total_size}, Got: {final_size}")
|
||||
# Don't treat this as fatal error, continue anyway
|
||||
|
||||
# Verify file size integrity before finalizing
|
||||
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0
|
||||
expected_size = total_size if total_size > 0 else None
|
||||
|
||||
integrity_error: Optional[str] = None
|
||||
if final_size <= 0:
|
||||
integrity_error = "Downloaded file is empty"
|
||||
elif expected_size is not None and final_size != expected_size:
|
||||
integrity_error = (
|
||||
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||
)
|
||||
|
||||
if integrity_error is not None:
|
||||
logger.error(
|
||||
"Download integrity check failed for %s: %s",
|
||||
save_path,
|
||||
integrity_error,
|
||||
)
|
||||
|
||||
# Remove the corrupted payload so future attempts start fresh
|
||||
if os.path.exists(part_path):
|
||||
try:
|
||||
os.remove(part_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete corrupted download %s: %s",
|
||||
part_path,
|
||||
remove_error,
|
||||
)
|
||||
if part_path != save_path and os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
except OSError as remove_error:
|
||||
logger.warning(
|
||||
"Failed to delete target file %s after integrity error: %s",
|
||||
save_path,
|
||||
remove_error,
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
if retry_count <= self.max_retries:
|
||||
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||
logger.info(
|
||||
"Retrying download in %s seconds due to integrity check failure",
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
resume_offset = 0
|
||||
total_size = 0
|
||||
await self._create_session()
|
||||
continue
|
||||
|
||||
return False, integrity_error
|
||||
|
||||
# Atomically rename .part to final file (only if using resume)
|
||||
if allow_resume and part_path != save_path:
|
||||
max_rename_attempts = 5
|
||||
@@ -326,18 +559,34 @@ class Downloader:
|
||||
else:
|
||||
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
|
||||
return False, f"Failed to finalize download: {str(e)}"
|
||||
|
||||
|
||||
final_size = os.path.getsize(save_path)
|
||||
|
||||
# Ensure 100% progress is reported
|
||||
if progress_callback:
|
||||
await progress_callback(100)
|
||||
final_snapshot = DownloadProgress(
|
||||
percent_complete=100.0,
|
||||
bytes_downloaded=final_size,
|
||||
total_bytes=total_size or final_size,
|
||||
bytes_per_second=0.0,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
)
|
||||
await self._dispatch_progress_callback(progress_callback, final_snapshot)
|
||||
|
||||
|
||||
return True, save_path
|
||||
|
||||
except (aiohttp.ClientError, aiohttp.ClientPayloadError,
|
||||
aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
aiohttp.ClientPayloadError,
|
||||
aiohttp.ServerDisconnectedError,
|
||||
asyncio.TimeoutError,
|
||||
DownloadStalledError,
|
||||
DownloadRestartRequested,
|
||||
) as e:
|
||||
retry_count += 1
|
||||
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}")
|
||||
|
||||
|
||||
if retry_count <= self.max_retries:
|
||||
# Calculate delay with exponential backoff
|
||||
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||
@@ -361,7 +610,24 @@ class Downloader:
|
||||
return False, str(e)
|
||||
|
||||
return False, f"Download failed after {self.max_retries + 1} attempts"
|
||||
|
||||
|
||||
async def _dispatch_progress_callback(
|
||||
self,
|
||||
progress_callback: Callable[..., Awaitable[None]],
|
||||
snapshot: DownloadProgress,
|
||||
) -> None:
|
||||
"""Invoke a progress callback while preserving backward compatibility."""
|
||||
|
||||
try:
|
||||
result = progress_callback(snapshot, snapshot)
|
||||
except TypeError:
|
||||
result = progress_callback(snapshot.percent_complete)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
elif hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
async def download_to_memory(
|
||||
self,
|
||||
url: str,
|
||||
@@ -511,6 +777,19 @@ class Downloader:
|
||||
return False, "Access forbidden"
|
||||
elif response.status == 404:
|
||||
return False, "Resource not found"
|
||||
elif response.status == 429:
|
||||
retry_after = self._extract_retry_after(response.headers)
|
||||
error_msg = "Request rate limited"
|
||||
logger.warning(
|
||||
"Rate limit encountered for %s %s; retry_after=%s",
|
||||
method,
|
||||
url,
|
||||
retry_after,
|
||||
)
|
||||
return False, RateLimitError(
|
||||
error_msg,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
else:
|
||||
return False, f"Request failed with status {response.status}"
|
||||
|
||||
@@ -532,6 +811,38 @@ class Downloader:
|
||||
await self._create_session()
|
||||
logger.info("HTTP session refreshed due to settings change")
|
||||
|
||||
@staticmethod
|
||||
def _extract_retry_after(headers) -> Optional[float]:
|
||||
"""Parse the Retry-After header into seconds."""
|
||||
if not headers:
|
||||
return None
|
||||
|
||||
header_value = headers.get("Retry-After")
|
||||
if not header_value:
|
||||
return None
|
||||
|
||||
header_value = header_value.strip()
|
||||
if not header_value:
|
||||
return None
|
||||
|
||||
if header_value.isdigit():
|
||||
try:
|
||||
seconds = float(header_value)
|
||||
except ValueError:
|
||||
return None
|
||||
return max(0.0, seconds)
|
||||
|
||||
try:
|
||||
retry_datetime = parsedate_to_datetime(header_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if retry_datetime.tzinfo is None:
|
||||
return None
|
||||
|
||||
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
|
||||
return max(0.0, delta.total_seconds())
|
||||
|
||||
|
||||
# Global instance accessor
|
||||
async def get_downloader() -> Downloader:
|
||||
|
||||
@@ -11,13 +11,14 @@ logger = logging.getLogger(__name__)
|
||||
class EmbeddingService(BaseModelService):
|
||||
"""Embedding-specific service implementation"""
|
||||
|
||||
def __init__(self, scanner):
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize Embedding service
|
||||
|
||||
Args:
|
||||
scanner: Embedding scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("embedding", scanner, EmbeddingMetadata)
|
||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, embedding_data: Dict) -> Dict:
|
||||
"""Format Embedding data for API response"""
|
||||
@@ -37,6 +38,7 @@ class EmbeddingService(BaseModelService):
|
||||
"notes": embedding_data.get("notes", ""),
|
||||
"model_type": embedding_data.get("model_type", "embedding"),
|
||||
"favorite": embedding_data.get("favorite", False),
|
||||
"update_available": bool(embedding_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
|
||||
27
py/services/errors.py
Normal file
27
py/services/errors.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Common service-level exception types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class RateLimitError(RuntimeError):
|
||||
"""Raised when a remote provider rejects a request due to rate limiting."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
retry_after: Optional[float] = None,
|
||||
provider: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
self.provider = provider
|
||||
|
||||
|
||||
class ResourceNotFoundError(RuntimeError):
|
||||
"""Raised when a remote resource is permanently missing."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -8,10 +8,11 @@ import os
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from .service_registry import ServiceRegistry
|
||||
from .settings_manager import settings
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.example_images_paths import iter_library_roots
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,7 +62,8 @@ class ExampleImagesCleanupService:
|
||||
async def cleanup_example_image_folders(self) -> Dict[str, object]:
|
||||
"""Clean empty or orphaned example image folders by moving them under a deleted bucket."""
|
||||
|
||||
example_images_path = settings.get("example_images_path")
|
||||
settings_manager = get_settings_manager()
|
||||
example_images_path = settings_manager.get("example_images_path")
|
||||
if not example_images_path:
|
||||
logger.debug("Cleanup skipped: example images path not configured")
|
||||
return {
|
||||
@@ -70,9 +72,9 @@ class ExampleImagesCleanupService:
|
||||
"error_code": "path_not_configured",
|
||||
}
|
||||
|
||||
example_root = Path(example_images_path)
|
||||
if not example_root.exists():
|
||||
logger.debug("Cleanup skipped: example images path missing -> %s", example_root)
|
||||
base_root = Path(example_images_path)
|
||||
if not base_root.exists():
|
||||
logger.debug("Cleanup skipped: example images path missing -> %s", base_root)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Example images path does not exist.",
|
||||
@@ -91,9 +93,6 @@ class ExampleImagesCleanupService:
|
||||
"error_code": "scanner_initialization_failed",
|
||||
}
|
||||
|
||||
deleted_bucket = example_root / self._deleted_folder_name
|
||||
deleted_bucket.mkdir(exist_ok=True)
|
||||
|
||||
checked_folders = 0
|
||||
moved_empty = 0
|
||||
moved_orphaned = 0
|
||||
@@ -101,45 +100,96 @@ class ExampleImagesCleanupService:
|
||||
move_failures = 0
|
||||
errors: List[str] = []
|
||||
|
||||
for entry in os.scandir(example_root):
|
||||
if not entry.is_dir(follow_symlinks=False):
|
||||
resolved_base = base_root.resolve()
|
||||
library_paths: List[Tuple[str, Path]] = []
|
||||
processed_paths = {resolved_base}
|
||||
|
||||
for library_name, library_path in iter_library_roots():
|
||||
if not library_path:
|
||||
continue
|
||||
|
||||
if entry.name == self._deleted_folder_name:
|
||||
continue
|
||||
|
||||
checked_folders += 1
|
||||
folder_path = Path(entry.path)
|
||||
|
||||
library_root = Path(library_path)
|
||||
try:
|
||||
if self._is_folder_empty(folder_path):
|
||||
if await self._remove_empty_folder(folder_path):
|
||||
moved_empty += 1
|
||||
else:
|
||||
move_failures += 1
|
||||
continue
|
||||
|
||||
if not self._is_hash_folder(entry.name):
|
||||
skipped_non_hash += 1
|
||||
continue
|
||||
|
||||
hash_exists = (
|
||||
lora_scanner.has_hash(entry.name)
|
||||
or checkpoint_scanner.has_hash(entry.name)
|
||||
or embedding_scanner.has_hash(entry.name)
|
||||
resolved = library_root.resolve()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if resolved in processed_paths:
|
||||
continue
|
||||
if not library_root.exists():
|
||||
logger.debug(
|
||||
"Skipping cleanup for library '%s': folder missing (%s)",
|
||||
library_name,
|
||||
library_root,
|
||||
)
|
||||
continue
|
||||
processed_paths.add(resolved)
|
||||
library_paths.append((library_name, library_root))
|
||||
|
||||
if not hash_exists:
|
||||
if await self._move_folder(folder_path, deleted_bucket):
|
||||
moved_orphaned += 1
|
||||
else:
|
||||
move_failures += 1
|
||||
deleted_roots: List[Path] = []
|
||||
|
||||
except Exception as exc: # pragma: no cover - filesystem guard
|
||||
move_failures += 1
|
||||
error_message = f"{entry.name}: {exc}"
|
||||
errors.append(error_message)
|
||||
logger.error("Error processing example images folder %s: %s", folder_path, exc, exc_info=True)
|
||||
# Build list of (label, root) pairs including the base root for legacy layouts
|
||||
cleanup_targets: List[Tuple[str, Path]] = [("__base__", base_root)] + library_paths
|
||||
|
||||
library_root_set = {root.resolve() for _, root in library_paths}
|
||||
|
||||
for label, root_path in cleanup_targets:
|
||||
deleted_bucket = root_path / self._deleted_folder_name
|
||||
deleted_bucket.mkdir(exist_ok=True)
|
||||
deleted_roots.append(deleted_bucket)
|
||||
|
||||
for entry in os.scandir(root_path):
|
||||
if not entry.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
|
||||
if entry.name == self._deleted_folder_name:
|
||||
continue
|
||||
|
||||
entry_path = Path(entry.path)
|
||||
|
||||
if label == "__base__":
|
||||
try:
|
||||
resolved_entry = entry_path.resolve()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if resolved_entry in library_root_set:
|
||||
# Skip library-specific folders tracked separately
|
||||
continue
|
||||
|
||||
checked_folders += 1
|
||||
|
||||
try:
|
||||
if self._is_folder_empty(entry_path):
|
||||
if await self._remove_empty_folder(entry_path):
|
||||
moved_empty += 1
|
||||
else:
|
||||
move_failures += 1
|
||||
continue
|
||||
|
||||
if not self._is_hash_folder(entry.name):
|
||||
skipped_non_hash += 1
|
||||
continue
|
||||
|
||||
hash_exists = (
|
||||
lora_scanner.has_hash(entry.name)
|
||||
or checkpoint_scanner.has_hash(entry.name)
|
||||
or embedding_scanner.has_hash(entry.name)
|
||||
)
|
||||
|
||||
if not hash_exists:
|
||||
if await self._move_folder(entry_path, deleted_bucket):
|
||||
moved_orphaned += 1
|
||||
else:
|
||||
move_failures += 1
|
||||
|
||||
except Exception as exc: # pragma: no cover - filesystem guard
|
||||
move_failures += 1
|
||||
error_message = f"{entry.name}: {exc}"
|
||||
errors.append(error_message)
|
||||
logger.error(
|
||||
"Error processing example images folder %s: %s",
|
||||
entry_path,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
partial_success = move_failures > 0 and (moved_empty > 0 or moved_orphaned > 0)
|
||||
success = move_failures == 0 and not errors
|
||||
@@ -152,11 +202,12 @@ class ExampleImagesCleanupService:
|
||||
skipped_non_hash=skipped_non_hash,
|
||||
move_failures=move_failures,
|
||||
errors=errors,
|
||||
deleted_root=str(deleted_bucket),
|
||||
deleted_root=str(deleted_roots[0]) if deleted_roots else None,
|
||||
partial_success=partial_success,
|
||||
)
|
||||
|
||||
summary = result.to_dict()
|
||||
summary["deleted_roots"] = [str(path) for path in deleted_roots]
|
||||
if success:
|
||||
logger.info(
|
||||
"Example images cleanup complete: checked=%s, moved_empty=%s, moved_orphaned=%s",
|
||||
|
||||
@@ -11,13 +11,14 @@ logger = logging.getLogger(__name__)
|
||||
class LoraService(BaseModelService):
|
||||
"""LoRA-specific service implementation"""
|
||||
|
||||
def __init__(self, scanner):
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize LoRA service
|
||||
|
||||
Args:
|
||||
scanner: LoRA scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("lora", scanner, LoraMetadata)
|
||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, lora_data: Dict) -> Dict:
|
||||
"""Format LoRA data for API response"""
|
||||
@@ -37,6 +38,7 @@ class LoraService(BaseModelService):
|
||||
"usage_tips": lora_data.get("usage_tips", ""),
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"civitai": self.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
|
||||
}
|
||||
|
||||
@@ -178,4 +180,4 @@ class LoraService(BaseModelService):
|
||||
|
||||
def find_duplicate_filenames(self) -> Dict:
|
||||
"""Find LoRAs with conflicting filenames"""
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from .downloader import get_downloader
|
||||
from .downloader import get_downloader, DownloadProgress
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,9 +77,15 @@ class MetadataArchiveManager:
|
||||
progress_callback("download", f"Downloading from {url}")
|
||||
|
||||
# Custom progress callback to report download progress
|
||||
async def download_progress(progress):
|
||||
async def download_progress(progress, snapshot=None):
|
||||
if progress_callback:
|
||||
progress_callback("download", f"Downloading archive... {progress:.1f}%")
|
||||
if isinstance(progress, DownloadProgress):
|
||||
percent = progress.percent_complete
|
||||
elif isinstance(snapshot, DownloadProgress):
|
||||
percent = snapshot.percent_complete
|
||||
else:
|
||||
percent = float(progress or 0)
|
||||
progress_callback("download", f"Downloading archive... {percent:.1f}%")
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
url=url,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import os
|
||||
import logging
|
||||
from .model_metadata_provider import (
|
||||
ModelMetadataProvider,
|
||||
ModelMetadataProviderManager,
|
||||
SQLiteModelMetadataProvider,
|
||||
CivitaiModelMetadataProvider,
|
||||
CivArchiveModelMetadataProvider,
|
||||
FallbackMetadataProvider
|
||||
)
|
||||
from .settings_manager import settings
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_archive_manager import MetadataArchiveManager
|
||||
from .service_registry import ServiceRegistry
|
||||
|
||||
@@ -21,7 +23,8 @@ async def initialize_metadata_providers():
|
||||
provider_manager.default_provider = None
|
||||
|
||||
# Get settings
|
||||
enable_archive_db = settings.get('enable_metadata_archive_db', False)
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
|
||||
providers = []
|
||||
|
||||
@@ -37,7 +40,7 @@ async def initialize_metadata_providers():
|
||||
sqlite_provider = SQLiteModelMetadataProvider(db_path)
|
||||
provider_manager.register_provider('sqlite', sqlite_provider)
|
||||
providers.append(('sqlite', sqlite_provider))
|
||||
logger.info(f"SQLite metadata provider registered with database: {db_path}")
|
||||
logger.debug(f"SQLite metadata provider registered with database: {db_path}")
|
||||
else:
|
||||
logger.warning("Metadata archive database is enabled but database file not found")
|
||||
except Exception as e:
|
||||
@@ -53,26 +56,27 @@ async def initialize_metadata_providers():
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
|
||||
|
||||
# Register CivArchive provider, but do NOT add to fallback providers
|
||||
# Register CivArchive provider, and all add to fallback providers
|
||||
try:
|
||||
from .model_metadata_provider import CivArchiveModelMetadataProvider
|
||||
civarchive_provider = CivArchiveModelMetadataProvider()
|
||||
provider_manager.register_provider('civarchive', civarchive_provider)
|
||||
logger.debug("CivArchive metadata provider registered (not included in fallback)")
|
||||
civarchive_client = await ServiceRegistry.get_civarchive_client()
|
||||
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
|
||||
provider_manager.register_provider('civarchive_api', civarchive_provider)
|
||||
providers.append(('civarchive_api', civarchive_provider))
|
||||
logger.debug("CivArchive metadata provider registered (also included in fallback)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
|
||||
|
||||
# Set up fallback provider based on available providers
|
||||
if len(providers) > 1:
|
||||
# Always use Civitai API first, then Archive DB
|
||||
ordered_providers = []
|
||||
ordered_providers.extend([p[1] for p in providers if p[0] == 'civitai_api'])
|
||||
ordered_providers.extend([p[1] for p in providers if p[0] == 'sqlite'])
|
||||
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
|
||||
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
|
||||
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
|
||||
|
||||
if ordered_providers:
|
||||
fallback_provider = FallbackMetadataProvider(ordered_providers)
|
||||
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
|
||||
logger.info(f"Fallback metadata provider registered with {len(ordered_providers)} providers, Civitai API first")
|
||||
elif len(providers) == 1:
|
||||
# Only one provider available, set it as default
|
||||
provider_name, provider = providers[0]
|
||||
@@ -87,7 +91,8 @@ async def update_metadata_providers():
|
||||
"""Update metadata providers based on current settings"""
|
||||
try:
|
||||
# Get current settings
|
||||
enable_archive_db = settings.get('enable_metadata_archive_db', False)
|
||||
settings_manager = get_settings_manager()
|
||||
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
|
||||
|
||||
# Reinitialize all providers with new settings
|
||||
provider_manager = await initialize_metadata_providers()
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
||||
|
||||
from ..services.settings_manager import SettingsManager
|
||||
from ..utils.model_utils import determine_base_model
|
||||
from .errors import RateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -153,7 +154,12 @@ class MetadataSyncService:
|
||||
model_data: Dict[str, Any],
|
||||
update_cache_func: Callable[[str, str, Dict[str, Any]], Awaitable[bool]],
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Fetch metadata for a model and update both disk and cache state."""
|
||||
"""Fetch metadata for a model and update both disk and cache state.
|
||||
|
||||
Callers should hydrate ``model_data`` via ``MetadataManager.hydrate_model_data``
|
||||
before invoking this method so that the persisted payload retains all known
|
||||
metadata fields.
|
||||
"""
|
||||
|
||||
if not isinstance(model_data, dict):
|
||||
error = f"Invalid model_data type: {type(model_data)}"
|
||||
@@ -162,41 +168,118 @@ class MetadataSyncService:
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
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")
|
||||
|
||||
try:
|
||||
if model_data.get("civitai_deleted") is True:
|
||||
if not enable_archive or model_data.get("db_checked") is True:
|
||||
return (
|
||||
False,
|
||||
"CivitAI model is deleted and metadata archive DB is not enabled",
|
||||
)
|
||||
metadata_provider = await self._get_provider("sqlite")
|
||||
else:
|
||||
metadata_provider = await self._get_default_provider()
|
||||
provider_attempts: list[tuple[Optional[str], MetadataProviderProtocol]] = []
|
||||
sqlite_attempted = False
|
||||
|
||||
civitai_metadata, error = await metadata_provider.get_model_by_hash(sha256)
|
||||
if not civitai_metadata:
|
||||
if error == "Model not found":
|
||||
if model_data.get("civitai_deleted") is True:
|
||||
if previous_source in (None, "civarchive"):
|
||||
try:
|
||||
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve civarchive provider: %s", exc)
|
||||
|
||||
if enable_archive and model_data.get("db_checked") is not True:
|
||||
try:
|
||||
provider_attempts.append(("sqlite", await self._get_provider("sqlite")))
|
||||
except Exception as exc: # pragma: no cover - provider resolution fault
|
||||
logger.debug("Unable to resolve sqlite provider: %s", exc)
|
||||
|
||||
if not provider_attempts:
|
||||
if not enable_archive:
|
||||
error_msg = "CivitAI model is deleted and metadata archive DB is not enabled"
|
||||
elif model_data.get("db_checked") is True:
|
||||
error_msg = "CivitAI model is deleted and not found in metadata archive DB"
|
||||
else:
|
||||
error_msg = "CivitAI model is deleted and no archive provider is available"
|
||||
return False, error_msg
|
||||
else:
|
||||
provider_attempts.append((None, await self._get_default_provider()))
|
||||
|
||||
civitai_metadata: Optional[Dict[str, Any]] = None
|
||||
metadata_provider: Optional[MetadataProviderProtocol] = None
|
||||
provider_used: Optional[str] = None
|
||||
last_error: Optional[str] = None
|
||||
|
||||
for provider_name, provider in provider_attempts:
|
||||
try:
|
||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||
civitai_metadata_candidate, error = None, str(exc)
|
||||
|
||||
if provider_name == "sqlite":
|
||||
sqlite_attempted = True
|
||||
|
||||
if civitai_metadata_candidate:
|
||||
civitai_metadata = civitai_metadata_candidate
|
||||
metadata_provider = provider
|
||||
provider_used = provider_name
|
||||
break
|
||||
|
||||
last_error = error or last_error
|
||||
|
||||
if civitai_metadata is None or metadata_provider is None:
|
||||
if sqlite_attempted:
|
||||
model_data["db_checked"] = True
|
||||
|
||||
if last_error == "Model not found":
|
||||
model_data["from_civitai"] = False
|
||||
model_data["civitai_deleted"] = True
|
||||
model_data["db_checked"] = enable_archive
|
||||
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
|
||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||
|
||||
data_to_save = model_data.copy()
|
||||
data_to_save.pop("folder", None)
|
||||
await self._metadata_manager.save_metadata(file_path, data_to_save)
|
||||
|
||||
default_error = (
|
||||
"CivitAI model is deleted and metadata archive DB is not enabled"
|
||||
if model_data.get("civitai_deleted") and not enable_archive
|
||||
else "CivitAI model is deleted and not found in metadata archive DB"
|
||||
if model_data.get("civitai_deleted") and (model_data.get("db_checked") is True or sqlite_attempted)
|
||||
else "No provider returned metadata"
|
||||
)
|
||||
|
||||
error_msg = (
|
||||
f"Error fetching metadata: {error} (model_name={model_data.get('model_name', '')})"
|
||||
f"Error fetching metadata: {last_error or default_error} "
|
||||
f"(model_name={model_data.get('model_name', '')})"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
model_data["from_civitai"] = True
|
||||
model_data["civitai_deleted"] = civitai_metadata.get("source") == "archive_db"
|
||||
model_data["db_checked"] = enable_archive
|
||||
model_data["civitai_deleted"] = civitai_metadata.get("source") == "archive_db" or civitai_metadata.get("source") == "civarchive"
|
||||
model_data["db_checked"] = enable_archive and (
|
||||
civitai_metadata.get("source") == "archive_db" or sqlite_attempted
|
||||
)
|
||||
source = civitai_metadata.get("source") or "civitai_api"
|
||||
if source == "api":
|
||||
source = "civitai_api"
|
||||
elif provider_used == "civarchive_api" and source != "civarchive":
|
||||
source = "civarchive"
|
||||
elif provider_used == "sqlite":
|
||||
source = "archive_db"
|
||||
model_data["metadata_source"] = source
|
||||
model_data["last_checked_at"] = datetime.now().timestamp()
|
||||
|
||||
readable_source = {
|
||||
"civitai_api": "CivitAI API",
|
||||
"civarchive": "CivArchive API",
|
||||
"archive_db": "Archive Database",
|
||||
}.get(source, source)
|
||||
|
||||
logger.info(
|
||||
"Fetched metadata for %s via %s",
|
||||
model_data.get("model_name", ""),
|
||||
readable_source,
|
||||
)
|
||||
|
||||
local_metadata = model_data.copy()
|
||||
local_metadata.pop("folder", None)
|
||||
|
||||
@@ -220,6 +303,16 @@ class MetadataSyncService:
|
||||
error_msg = f"Error fetching metadata - Missing key: {exc} in model_data={model_data}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
except RateLimitError as exc:
|
||||
provider_label = exc.provider or "metadata provider"
|
||||
wait_hint = (
|
||||
f"; retry after approximately {int(exc.retry_after)}s"
|
||||
if exc.retry_after and exc.retry_after > 0
|
||||
else ""
|
||||
)
|
||||
error_msg = f"Rate limited by {provider_label}{wait_hint}"
|
||||
logger.warning(error_msg)
|
||||
return False, error_msg
|
||||
except Exception as exc: # pragma: no cover - error path
|
||||
error_msg = f"Error fetching metadata: {exc}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
@@ -251,15 +344,6 @@ class MetadataSyncService:
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
primary_model_file: Optional[Dict[str, Any]] = None
|
||||
for file_info in civitai_metadata.get("files", []):
|
||||
if file_info.get("primary", False) and file_info.get("type") == "Model":
|
||||
primary_model_file = file_info
|
||||
break
|
||||
|
||||
if primary_model_file and primary_model_file.get("hashes", {}).get("SHA256"):
|
||||
metadata["sha256"] = primary_model_file["hashes"]["SHA256"].lower()
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
|
||||
await self.update_model_metadata(
|
||||
metadata_path,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
from typing import List, Dict, Tuple
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
@@ -15,19 +15,182 @@ SUPPORTED_SORT_MODES = [
|
||||
('size', 'desc'),
|
||||
]
|
||||
|
||||
DISPLAY_NAME_MODES = {"model_name", "file_name"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelCache:
|
||||
"""Cache structure for model data with extensible sorting"""
|
||||
"""Cache structure for model data with extensible sorting."""
|
||||
|
||||
raw_data: List[Dict]
|
||||
folders: List[str]
|
||||
|
||||
version_index: Dict[int, Dict] = field(default_factory=dict)
|
||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
name_display_mode: str = "model_name"
|
||||
|
||||
def __post_init__(self):
|
||||
self._lock = asyncio.Lock()
|
||||
# Cache for last sort: (sort_key, order) -> sorted list
|
||||
self._last_sort: Tuple[str, str] = (None, None)
|
||||
self._last_sorted_data: List[Dict] = []
|
||||
self._normalize_raw_data()
|
||||
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
||||
# Default sort on init
|
||||
asyncio.create_task(self.resort())
|
||||
self.rebuild_version_index()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_display_mode(value: Optional[str]) -> str:
|
||||
if isinstance(value, str) and value in DISPLAY_NAME_MODES:
|
||||
return value
|
||||
return "model_name"
|
||||
|
||||
@staticmethod
|
||||
def _ensure_string(value: Any) -> str:
|
||||
"""Return a safe string representation for metadata fields."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
def _normalize_item(self, item: Dict) -> None:
|
||||
"""Ensure core metadata fields are present and string typed."""
|
||||
|
||||
if not isinstance(item, dict):
|
||||
return
|
||||
|
||||
for field in ("model_name", "file_name", "folder"):
|
||||
if field in item:
|
||||
item[field] = self._ensure_string(item.get(field))
|
||||
|
||||
def _normalize_raw_data(self) -> None:
|
||||
"""Normalize every cached entry before it is consumed."""
|
||||
|
||||
for item in self.raw_data:
|
||||
self._normalize_item(item)
|
||||
|
||||
def _get_display_name(self, item: Dict) -> str:
|
||||
"""Return the value used for name-based sorting based on display settings."""
|
||||
|
||||
if self.name_display_mode == "file_name":
|
||||
primary = self._ensure_string(item.get("file_name"))
|
||||
fallback = self._ensure_string(item.get("model_name"))
|
||||
else:
|
||||
primary = self._ensure_string(item.get("model_name"))
|
||||
fallback = self._ensure_string(item.get("file_name"))
|
||||
|
||||
candidate = primary or fallback
|
||||
return candidate or ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_version_id(value: Any) -> Optional[int]:
|
||||
"""Normalize a potential version identifier into an integer."""
|
||||
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def rebuild_version_index(self) -> None:
|
||||
"""Rebuild the version and model indexes from the current raw data."""
|
||||
|
||||
self.version_index = {}
|
||||
self.model_id_index = {}
|
||||
for item in self.raw_data:
|
||||
self.add_to_version_index(item)
|
||||
|
||||
def add_to_version_index(self, item: Dict) -> None:
|
||||
"""Register a cache item in the version/model indexes if possible."""
|
||||
|
||||
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||
if not isinstance(civitai_data, dict):
|
||||
return
|
||||
|
||||
version_id = self._normalize_version_id(civitai_data.get('id'))
|
||||
if version_id is None:
|
||||
return
|
||||
|
||||
self.version_index[version_id] = item
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
return
|
||||
|
||||
descriptor = self._build_version_descriptor(item, civitai_data, version_id)
|
||||
if descriptor is None:
|
||||
return
|
||||
|
||||
versions = self.model_id_index.setdefault(model_id, [])
|
||||
for index, existing in enumerate(versions):
|
||||
if existing.get('versionId') == descriptor['versionId']:
|
||||
versions[index] = descriptor
|
||||
break
|
||||
else:
|
||||
versions.append(descriptor)
|
||||
|
||||
def remove_from_version_index(self, item: Dict) -> None:
|
||||
"""Remove a cache item from the version/model indexes if present."""
|
||||
|
||||
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||
if not isinstance(civitai_data, dict):
|
||||
return
|
||||
|
||||
version_id = self._normalize_version_id(civitai_data.get('id'))
|
||||
if version_id is None:
|
||||
return
|
||||
|
||||
existing = self.version_index.get(version_id)
|
||||
if existing is item or (
|
||||
isinstance(existing, dict)
|
||||
and existing.get('file_path') == item.get('file_path')
|
||||
):
|
||||
self.version_index.pop(version_id, None)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
return
|
||||
|
||||
versions = self.model_id_index.get(model_id)
|
||||
if not versions:
|
||||
return
|
||||
|
||||
filtered = [v for v in versions if v.get('versionId') != version_id]
|
||||
if filtered:
|
||||
self.model_id_index[model_id] = filtered
|
||||
else:
|
||||
self.model_id_index.pop(model_id, None)
|
||||
|
||||
def _build_version_descriptor(
|
||||
self,
|
||||
item: Dict,
|
||||
civitai_data: Dict[str, Any],
|
||||
version_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Create a lightweight descriptor for a version entry."""
|
||||
|
||||
model_name = self._ensure_string(civitai_data.get('name'))
|
||||
file_name = self._ensure_string(item.get('file_name'))
|
||||
return {
|
||||
'versionId': version_id,
|
||||
'name': model_name,
|
||||
'fileName': file_name,
|
||||
}
|
||||
|
||||
def get_versions_by_model_id(self, model_id: Any) -> List[Dict[str, Any]]:
|
||||
"""Return cached version descriptors for a given model ID."""
|
||||
|
||||
normalized_id = self._normalize_version_id(model_id)
|
||||
if normalized_id is None:
|
||||
return []
|
||||
|
||||
versions = self.model_id_index.get(normalized_id, [])
|
||||
return [dict(version) for version in versions]
|
||||
|
||||
async def resort(self):
|
||||
"""Resort cached data according to last sort mode if set"""
|
||||
@@ -39,17 +202,22 @@ class ModelCache:
|
||||
# Update folder list
|
||||
# else: do nothing
|
||||
|
||||
all_folders = set(l['folder'] for l in self.raw_data)
|
||||
all_folders = {
|
||||
self._ensure_string(item.get('folder'))
|
||||
for item in self.raw_data
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
self.rebuild_version_index()
|
||||
|
||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
|
||||
"""Sort data by sort_key and order"""
|
||||
reverse = (order == 'desc')
|
||||
if sort_key == 'name':
|
||||
# Natural sort by model_name, case-insensitive
|
||||
# Natural sort by configured display name, case-insensitive
|
||||
return natsorted(
|
||||
data,
|
||||
key=lambda x: x['model_name'].lower(),
|
||||
key=lambda x: self._get_display_name(x).lower(),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'date':
|
||||
@@ -80,6 +248,20 @@ class ModelCache:
|
||||
self._last_sorted_data = sorted_data
|
||||
return sorted_data
|
||||
|
||||
async def update_name_display_mode(self, display_mode: str) -> None:
|
||||
"""Update the display mode used for name sorting and refresh cached results."""
|
||||
|
||||
normalized = self._normalize_display_mode(display_mode)
|
||||
async with self._lock:
|
||||
if self.name_display_mode == normalized:
|
||||
return
|
||||
|
||||
self.name_display_mode = normalized
|
||||
|
||||
if self._last_sort[0] == 'name':
|
||||
sort_key, order = self._last_sort
|
||||
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order)
|
||||
|
||||
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
||||
"""Update preview_url for a specific model in all cached data
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
|
||||
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -114,7 +114,8 @@ class ModelFileService:
|
||||
raise ValueError('No model roots configured')
|
||||
|
||||
# Check if flat structure is configured for this model type
|
||||
path_template = settings.get_download_path_template(self.model_type)
|
||||
settings_manager = get_settings_manager()
|
||||
path_template = settings_manager.get_download_path_template(self.model_type)
|
||||
result.is_flat_structure = not path_template
|
||||
|
||||
# Initialize tracking
|
||||
|
||||
@@ -236,10 +236,20 @@ class ModelLifecycleService:
|
||||
def _get_multipart_ext(filename: str) -> str:
|
||||
"""Return the extension for files with compound suffixes."""
|
||||
|
||||
parts = filename.split(".")
|
||||
if len(parts) == 3:
|
||||
return "." + ".".join(parts[-2:])
|
||||
if len(parts) >= 4:
|
||||
return "." + ".".join(parts[-3:])
|
||||
return os.path.splitext(filename)[1]
|
||||
known_suffixes = [
|
||||
".metadata.json.bak",
|
||||
".metadata.json",
|
||||
".safetensors",
|
||||
*PREVIEW_EXTENSIONS,
|
||||
]
|
||||
|
||||
for suffix in sorted(known_suffixes, key=len, reverse=True):
|
||||
if filename.endswith(suffix):
|
||||
return suffix
|
||||
|
||||
basename = os.path.basename(filename)
|
||||
dot_index = basename.find(".")
|
||||
if dot_index != -1:
|
||||
return basename[dot_index:]
|
||||
|
||||
return os.path.splitext(basename)[1]
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Tuple, Any
|
||||
import random
|
||||
from typing import Optional, Dict, Tuple, Any, List, Sequence
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -50,6 +53,12 @@ class ModelMetadataProvider(ABC):
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Get all versions of a model with their details"""
|
||||
pass
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model versions for multiple model ids when supported."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
@@ -61,6 +70,11 @@ class ModelMetadataProvider(ABC):
|
||||
"""Fetch model version metadata"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
"""Fetch models owned by the specified user"""
|
||||
pass
|
||||
|
||||
class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Provider that uses Civitai API for metadata"""
|
||||
|
||||
@@ -72,6 +86,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
return await self.client.get_model_versions(model_id)
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self, model_ids: Sequence[int]
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
return await self.client.get_model_versions_bulk(model_ids)
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
return await self.client.get_model_version(model_id, version_id)
|
||||
@@ -79,123 +98,30 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
return await self.client.get_model_version_info(version_id)
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
return await self.client.get_user_models(username)
|
||||
|
||||
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Provider that uses CivArchive HTML page parsing for metadata"""
|
||||
"""Provider that uses CivArchive API for metadata"""
|
||||
|
||||
def __init__(self, civarchive_client):
|
||||
self.client = civarchive_client
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Not supported by CivArchive provider"""
|
||||
return None, "CivArchive provider does not support hash lookup"
|
||||
return await self.client.get_model_by_hash(model_hash)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
"""Not supported by CivArchive provider"""
|
||||
return None
|
||||
return await self.client.get_model_versions(model_id)
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
"""Get specific model version by parsing CivArchive HTML page"""
|
||||
if model_id is None or version_id is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Construct CivArchive URL
|
||||
url = f"https://civarchive.com/models/{model_id}?modelVersionId={version_id}"
|
||||
|
||||
downloader = await get_downloader()
|
||||
session = await downloader.session
|
||||
async with session.get(url) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
|
||||
html_content = await response.text()
|
||||
|
||||
# Parse HTML to extract JSON data
|
||||
soup_parser = _require_beautifulsoup()
|
||||
soup = soup_parser(html_content, 'html.parser')
|
||||
script_tag = soup.find('script', {'id': '__NEXT_DATA__', 'type': 'application/json'})
|
||||
|
||||
if not script_tag:
|
||||
return None
|
||||
|
||||
# Parse JSON content
|
||||
json_data = json.loads(script_tag.string)
|
||||
model_data = json_data.get('props', {}).get('pageProps', {}).get('model')
|
||||
|
||||
if not model_data or 'version' not in model_data:
|
||||
return None
|
||||
|
||||
# Extract version data as base
|
||||
version = model_data['version'].copy()
|
||||
|
||||
# Restructure stats
|
||||
if 'downloadCount' in version and 'ratingCount' in version and 'rating' in version:
|
||||
version['stats'] = {
|
||||
'downloadCount': version.pop('downloadCount'),
|
||||
'ratingCount': version.pop('ratingCount'),
|
||||
'rating': version.pop('rating')
|
||||
}
|
||||
|
||||
# Rename trigger to trainedWords
|
||||
if 'trigger' in version:
|
||||
version['trainedWords'] = version.pop('trigger')
|
||||
|
||||
# Transform files data to expected format
|
||||
if 'files' in version:
|
||||
transformed_files = []
|
||||
for file_data in version['files']:
|
||||
# Find first available mirror (deletedAt is null)
|
||||
available_mirror = None
|
||||
for mirror in file_data.get('mirrors', []):
|
||||
if mirror.get('deletedAt') is None:
|
||||
available_mirror = mirror
|
||||
break
|
||||
|
||||
# Create transformed file entry
|
||||
transformed_file = {
|
||||
'id': file_data.get('id'),
|
||||
'sizeKB': file_data.get('sizeKB'),
|
||||
'name': available_mirror.get('filename', file_data.get('name')) if available_mirror else file_data.get('name'),
|
||||
'type': file_data.get('type'),
|
||||
'downloadUrl': available_mirror.get('url') if available_mirror else None,
|
||||
'primary': True,
|
||||
'mirrors': file_data.get('mirrors', [])
|
||||
}
|
||||
|
||||
# Transform hash format
|
||||
if 'sha256' in file_data:
|
||||
transformed_file['hashes'] = {
|
||||
'SHA256': file_data['sha256'].upper()
|
||||
}
|
||||
|
||||
transformed_files.append(transformed_file)
|
||||
|
||||
version['files'] = transformed_files
|
||||
|
||||
# Add model information
|
||||
version['model'] = {
|
||||
'name': model_data.get('name'),
|
||||
'type': model_data.get('type'),
|
||||
'nsfw': model_data.get('is_nsfw', False),
|
||||
'description': model_data.get('description'),
|
||||
'tags': model_data.get('tags', [])
|
||||
}
|
||||
|
||||
version['creator'] = {
|
||||
'username': model_data.get('username'),
|
||||
'image': ''
|
||||
}
|
||||
|
||||
# Add source identifier
|
||||
version['source'] = 'civarchive'
|
||||
version['is_deleted'] = json_data.get('query', {}).get('is_deleted', False)
|
||||
|
||||
return version
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model version {model_id}/{version_id}: {e}")
|
||||
return None
|
||||
return await self.client.get_model_version(model_id, version_id)
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Not supported by CivArchive provider - requires both model_id and version_id"""
|
||||
return None, "CivArchive provider requires both model_id and version_id"
|
||||
return await self.client.get_model_version_info(version_id)
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
"""Not supported by CivArchive provider"""
|
||||
return None
|
||||
|
||||
class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Provider that uses SQLite database for metadata"""
|
||||
@@ -329,20 +255,24 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Fetch model version metadata from SQLite database"""
|
||||
async with self._aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = self._aiosqlite.Row
|
||||
|
||||
|
||||
# Get version details
|
||||
version_query = "SELECT model_id FROM model_versions WHERE id = ?"
|
||||
cursor = await db.execute(version_query, (version_id,))
|
||||
version_row = await cursor.fetchone()
|
||||
|
||||
|
||||
if not version_row:
|
||||
return None, "Model version not found"
|
||||
|
||||
|
||||
model_id = version_row['model_id']
|
||||
|
||||
|
||||
# Build complete version data with model info
|
||||
version_data = await self._get_version_with_model_data(db, model_id, version_id)
|
||||
return version_data, None
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
"""Listing models by username is not supported for archive database"""
|
||||
return None
|
||||
|
||||
async def _get_version_with_model_data(self, db, model_id, version_id) -> Optional[Dict]:
|
||||
"""Helper to build version data with model information"""
|
||||
@@ -389,59 +319,211 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
||||
# Add any additional fields from version data
|
||||
result.update(version_data)
|
||||
|
||||
# Attach files associated with this version from model_files table
|
||||
files_query = """
|
||||
SELECT data
|
||||
FROM model_files
|
||||
WHERE version_id = ? AND type = 'Model'
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
cursor = await db.execute(files_query, (version_id,))
|
||||
file_rows = await cursor.fetchall()
|
||||
|
||||
files = []
|
||||
for file_row in file_rows:
|
||||
try:
|
||||
file_data = json.loads(file_row['data'])
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Skipping model_files entry with invalid JSON for version_id %s", version_id
|
||||
)
|
||||
continue
|
||||
# Remove 'modelId' and 'modelVersionId' fields if present
|
||||
file_data.pop('modelId', None)
|
||||
file_data.pop('modelVersionId', None)
|
||||
files.append(file_data)
|
||||
|
||||
if 'files' in result:
|
||||
existing_files = result['files']
|
||||
if isinstance(existing_files, list):
|
||||
existing_files.extend(files)
|
||||
result['files'] = existing_files
|
||||
else:
|
||||
merged_files = files.copy()
|
||||
if existing_files:
|
||||
merged_files.insert(0, existing_files)
|
||||
result['files'] = merged_files
|
||||
elif files:
|
||||
result['files'] = files
|
||||
else:
|
||||
result['files'] = []
|
||||
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
"""Try providers in order, return first successful result."""
|
||||
def __init__(self, providers: list):
|
||||
self.providers = providers
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
providers: Sequence[ModelMetadataProvider | Tuple[str, ModelMetadataProvider]],
|
||||
*,
|
||||
rate_limit_retry_limit: int = 3,
|
||||
rate_limit_base_delay: float = 1.5,
|
||||
rate_limit_max_delay: float = 30.0,
|
||||
rate_limit_jitter_ratio: float = 0.2,
|
||||
) -> None:
|
||||
self.providers: List[ModelMetadataProvider] = []
|
||||
self._provider_labels: List[str] = []
|
||||
|
||||
for entry in providers:
|
||||
if isinstance(entry, tuple) and len(entry) == 2:
|
||||
name, provider = entry
|
||||
else:
|
||||
provider = entry
|
||||
name = provider.__class__.__name__
|
||||
self.providers.append(provider)
|
||||
self._provider_labels.append(str(name))
|
||||
|
||||
self._rate_limit_retry_limit = max(1, rate_limit_retry_limit)
|
||||
self._rate_limit_base_delay = rate_limit_base_delay
|
||||
self._rate_limit_max_delay = rate_limit_max_delay
|
||||
self._rate_limit_jitter_ratio = max(0.0, rate_limit_jitter_ratio)
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
for provider in self.providers:
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
result, error = await provider.get_model_by_hash(model_hash)
|
||||
result, error = await self._call_with_rate_limit(
|
||||
label,
|
||||
provider.get_model_by_hash,
|
||||
model_hash,
|
||||
)
|
||||
if result:
|
||||
return result, error
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
except Exception as e:
|
||||
logger.debug(f"Provider failed for get_model_by_hash: {e}")
|
||||
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
||||
continue
|
||||
return None, "Model not found"
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
||||
for provider in self.providers:
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
result = await provider.get_model_versions(model_id)
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
provider.get_model_versions,
|
||||
model_id,
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
except Exception as e:
|
||||
logger.debug(f"Provider failed for get_model_versions: {e}")
|
||||
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
|
||||
continue
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
||||
for provider in self.providers:
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
result = await provider.get_model_version(model_id, version_id)
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
provider.get_model_version,
|
||||
model_id,
|
||||
version_id,
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
except Exception as e:
|
||||
logger.debug(f"Provider failed for get_model_version: {e}")
|
||||
logger.debug("Provider %s failed for get_model_version: %s", label, e)
|
||||
continue
|
||||
return None
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
for provider in self.providers:
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
result, error = await provider.get_model_version_info(version_id)
|
||||
result, error = await self._call_with_rate_limit(
|
||||
label,
|
||||
provider.get_model_version_info,
|
||||
version_id,
|
||||
)
|
||||
if result:
|
||||
return result, error
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
except Exception as e:
|
||||
logger.debug(f"Provider failed for get_model_version_info: {e}")
|
||||
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
||||
continue
|
||||
return None, "No provider could retrieve the data"
|
||||
|
||||
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
|
||||
for provider, label in self._iter_providers():
|
||||
try:
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
provider.get_user_models,
|
||||
username,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_user_models: %s", label, e)
|
||||
continue
|
||||
return None
|
||||
|
||||
def _iter_providers(self):
|
||||
return zip(self.providers, self._provider_labels)
|
||||
|
||||
async def _call_with_rate_limit(
|
||||
self,
|
||||
label: str,
|
||||
func,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except RateLimitError as exc:
|
||||
attempt += 1
|
||||
if attempt >= self._rate_limit_retry_limit:
|
||||
exc.provider = exc.provider or label
|
||||
raise exc
|
||||
delay = self._calculate_rate_limit_delay(exc.retry_after, attempt)
|
||||
logger.warning(
|
||||
"Provider %s rate limited request; retrying in %.2fs (attempt %s/%s)",
|
||||
label,
|
||||
delay,
|
||||
attempt,
|
||||
self._rate_limit_retry_limit,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
def _calculate_rate_limit_delay(self, retry_after: Optional[float], attempt: int) -> float:
|
||||
if retry_after is not None:
|
||||
return min(self._rate_limit_max_delay, max(0.0, retry_after))
|
||||
|
||||
base_delay = self._rate_limit_base_delay * (2 ** max(0, attempt - 1))
|
||||
jitter_span = base_delay * self._rate_limit_jitter_ratio
|
||||
if jitter_span > 0:
|
||||
base_delay += random.uniform(-jitter_span, jitter_span)
|
||||
|
||||
return min(self._rate_limit_max_delay, max(0.0, base_delay))
|
||||
|
||||
class ModelMetadataProviderManager:
|
||||
"""Manager for selecting and using model metadata providers"""
|
||||
|
||||
@@ -473,7 +555,19 @@ class ModelMetadataProviderManager:
|
||||
"""Get model versions using specified or default provider"""
|
||||
provider = self._get_provider(provider_name)
|
||||
return await provider.get_model_versions(model_id)
|
||||
|
||||
|
||||
async def get_model_versions_bulk(
|
||||
self,
|
||||
model_ids: Sequence[int],
|
||||
provider_name: str = None,
|
||||
) -> Optional[Dict[int, Dict]]:
|
||||
"""Fetch model versions for multiple model ids when supported by provider."""
|
||||
provider = self._get_provider(provider_name)
|
||||
try:
|
||||
return await provider.get_model_versions_bulk(model_ids)
|
||||
except NotImplementedError:
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: int = None, version_id: int = None, provider_name: str = None) -> Optional[Dict]:
|
||||
"""Get specific model version using specified or default provider"""
|
||||
provider = self._get_provider(provider_name)
|
||||
@@ -483,6 +577,11 @@ class ModelMetadataProviderManager:
|
||||
"""Fetch model version info using specified or default provider"""
|
||||
provider = self._get_provider(provider_name)
|
||||
return await provider.get_model_version_info(version_id)
|
||||
|
||||
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
|
||||
"""Fetch models owned by the specified user"""
|
||||
provider = self._get_provider(provider_name)
|
||||
return await provider.get_user_models(username)
|
||||
|
||||
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
|
||||
"""Get provider by name or default provider"""
|
||||
|
||||
@@ -187,6 +187,9 @@ class SearchStrategy:
|
||||
return results
|
||||
|
||||
def _matches(self, candidate: str, search_term: str, search_lower: str, fuzzy: bool) -> bool:
|
||||
if not isinstance(candidate, str):
|
||||
candidate = "" if candidate is None else str(candidate)
|
||||
|
||||
if not candidate:
|
||||
return False
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1231
py/services/model_update_service.py
Normal file
1231
py/services/model_update_service.py
Normal file
File diff suppressed because it is too large
Load Diff
569
py/services/persistent_model_cache.py
Normal file
569
py/services/persistent_model_cache.py
Normal file
@@ -0,0 +1,569 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from ..utils.settings_paths import get_project_root, get_settings_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistedCacheData:
|
||||
"""Lightweight structure returned by the persistent cache."""
|
||||
|
||||
raw_data: List[Dict]
|
||||
hash_rows: List[Tuple[str, str]]
|
||||
excluded_models: List[str]
|
||||
|
||||
|
||||
class PersistentModelCache:
|
||||
"""Persist core model metadata and hash index data in SQLite."""
|
||||
|
||||
_DEFAULT_FILENAME = "model_cache.sqlite"
|
||||
_MODEL_COLUMNS: Tuple[str, ...] = (
|
||||
"model_type",
|
||||
"file_path",
|
||||
"file_name",
|
||||
"model_name",
|
||||
"folder",
|
||||
"size",
|
||||
"modified",
|
||||
"sha256",
|
||||
"base_model",
|
||||
"preview_url",
|
||||
"preview_nsfw_level",
|
||||
"from_civitai",
|
||||
"favorite",
|
||||
"notes",
|
||||
"usage_tips",
|
||||
"metadata_source",
|
||||
"civitai_id",
|
||||
"civitai_model_id",
|
||||
"civitai_name",
|
||||
"civitai_creator_username",
|
||||
"trained_words",
|
||||
"civitai_deleted",
|
||||
"exclude",
|
||||
"db_checked",
|
||||
"last_checked_at",
|
||||
)
|
||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def __init__(self, library_name: str = "default", db_path: Optional[str] = None) -> None:
|
||||
self._library_name = library_name or "default"
|
||||
self._db_path = db_path or self._resolve_default_path(self._library_name)
|
||||
self._db_lock = threading.Lock()
|
||||
self._schema_initialized = False
|
||||
try:
|
||||
directory = os.path.dirname(self._db_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Could not create cache directory %s: %s", directory, exc)
|
||||
if self.is_enabled():
|
||||
self._initialize_schema()
|
||||
|
||||
@classmethod
|
||||
def get_default(cls, library_name: Optional[str] = None) -> "PersistentModelCache":
|
||||
name = (library_name or "default")
|
||||
with cls._instance_lock:
|
||||
if name not in cls._instances:
|
||||
cls._instances[name] = cls(name)
|
||||
return cls._instances[name]
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return os.environ.get("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "0") != "1"
|
||||
|
||||
def get_database_path(self) -> str:
|
||||
"""Expose the resolved SQLite database path."""
|
||||
|
||||
return self._db_path
|
||||
|
||||
def load_cache(self, model_type: str) -> Optional[PersistedCacheData]:
|
||||
if not self.is_enabled():
|
||||
return None
|
||||
if not self._schema_initialized:
|
||||
self._initialize_schema()
|
||||
if not self._schema_initialized:
|
||||
return None
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
model_columns_sql = ", ".join(self._MODEL_COLUMNS[1:])
|
||||
rows = conn.execute(
|
||||
f"SELECT {model_columns_sql} FROM models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
tags = self._load_tags(conn, model_type)
|
||||
hash_rows = conn.execute(
|
||||
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
excluded = conn.execute(
|
||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load persisted cache for %s: %s", model_type, exc)
|
||||
return None
|
||||
|
||||
raw_data: List[Dict] = []
|
||||
for row in rows:
|
||||
file_path: str = row["file_path"]
|
||||
trained_words = []
|
||||
if row["trained_words"]:
|
||||
try:
|
||||
trained_words = json.loads(row["trained_words"])
|
||||
except json.JSONDecodeError:
|
||||
trained_words = []
|
||||
|
||||
creator_username = row["civitai_creator_username"]
|
||||
civitai: Optional[Dict] = None
|
||||
civitai_has_data = any(
|
||||
row[col] is not None for col in ("civitai_id", "civitai_model_id", "civitai_name")
|
||||
) or trained_words or creator_username
|
||||
if civitai_has_data:
|
||||
civitai = {}
|
||||
if row["civitai_id"] is not None:
|
||||
civitai["id"] = row["civitai_id"]
|
||||
if row["civitai_model_id"] is not None:
|
||||
civitai["modelId"] = row["civitai_model_id"]
|
||||
if row["civitai_name"]:
|
||||
civitai["name"] = row["civitai_name"]
|
||||
if trained_words:
|
||||
civitai["trainedWords"] = trained_words
|
||||
if creator_username:
|
||||
civitai.setdefault("creator", {})["username"] = creator_username
|
||||
|
||||
item = {
|
||||
"file_path": file_path,
|
||||
"file_name": row["file_name"],
|
||||
"model_name": row["model_name"],
|
||||
"folder": row["folder"] or "",
|
||||
"size": row["size"] or 0,
|
||||
"modified": row["modified"] or 0.0,
|
||||
"sha256": row["sha256"] or "",
|
||||
"base_model": row["base_model"] or "",
|
||||
"preview_url": row["preview_url"] or "",
|
||||
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
|
||||
"from_civitai": bool(row["from_civitai"]),
|
||||
"favorite": bool(row["favorite"]),
|
||||
"notes": row["notes"] or "",
|
||||
"usage_tips": row["usage_tips"] or "",
|
||||
"metadata_source": row["metadata_source"] or None,
|
||||
"exclude": bool(row["exclude"]),
|
||||
"db_checked": bool(row["db_checked"]),
|
||||
"last_checked_at": row["last_checked_at"] or 0.0,
|
||||
"tags": tags.get(file_path, []),
|
||||
"civitai": civitai,
|
||||
"civitai_deleted": bool(row["civitai_deleted"]),
|
||||
}
|
||||
raw_data.append(item)
|
||||
|
||||
hash_pairs = [(entry["sha256"].lower(), entry["file_path"]) for entry in hash_rows if entry["sha256"]]
|
||||
if not hash_pairs:
|
||||
# Fall back to hashes stored on the model rows
|
||||
for item in raw_data:
|
||||
sha_value = item.get("sha256")
|
||||
if sha_value:
|
||||
hash_pairs.append((sha_value.lower(), item["file_path"]))
|
||||
|
||||
excluded_paths = [row["file_path"] for row in excluded]
|
||||
return PersistedCacheData(raw_data=raw_data, hash_rows=hash_pairs, excluded_models=excluded_paths)
|
||||
|
||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict], hash_index: Dict[str, List[str]], excluded_models: Sequence[str]) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
if not self._schema_initialized:
|
||||
self._initialize_schema()
|
||||
if not self._schema_initialized:
|
||||
return
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN")
|
||||
|
||||
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
|
||||
model_map: Dict[str, Tuple] = {
|
||||
row[1]: row for row in model_rows if row[1] # row[1] is file_path
|
||||
}
|
||||
|
||||
existing_models = conn.execute(
|
||||
"SELECT "
|
||||
+ ", ".join(self._MODEL_COLUMNS[1:])
|
||||
+ " FROM models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_model_map: Dict[str, sqlite3.Row] = {
|
||||
row["file_path"]: row for row in existing_models
|
||||
}
|
||||
|
||||
to_remove_models = [
|
||||
(model_type, path)
|
||||
for path in existing_model_map.keys()
|
||||
if path not in model_map
|
||||
]
|
||||
if to_remove_models:
|
||||
conn.executemany(
|
||||
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
conn.executemany(
|
||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||
to_remove_models,
|
||||
)
|
||||
|
||||
insert_rows: List[Tuple] = []
|
||||
update_rows: List[Tuple] = []
|
||||
|
||||
for file_path, row in model_map.items():
|
||||
existing = existing_model_map.get(file_path)
|
||||
if existing is None:
|
||||
insert_rows.append(row)
|
||||
continue
|
||||
|
||||
existing_values = tuple(
|
||||
existing[column] for column in self._MODEL_COLUMNS[1:]
|
||||
)
|
||||
current_values = row[1:]
|
||||
if existing_values != current_values:
|
||||
update_rows.append(row[2:] + (model_type, file_path))
|
||||
|
||||
if insert_rows:
|
||||
conn.executemany(self._insert_model_sql(), insert_rows)
|
||||
|
||||
if update_rows:
|
||||
set_clause = ", ".join(
|
||||
f"{column} = ?"
|
||||
for column in self._MODEL_UPDATE_COLUMNS
|
||||
)
|
||||
update_sql = (
|
||||
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
|
||||
)
|
||||
conn.executemany(update_sql, update_rows)
|
||||
|
||||
existing_tags_rows = conn.execute(
|
||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_tags: Dict[str, set] = {}
|
||||
for row in existing_tags_rows:
|
||||
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
|
||||
|
||||
new_tags: Dict[str, set] = {}
|
||||
for item in raw_data:
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
tags = set(item.get("tags") or [])
|
||||
if tags:
|
||||
new_tags[file_path] = tags
|
||||
|
||||
tag_inserts: List[Tuple[str, str, str]] = []
|
||||
tag_deletes: List[Tuple[str, str, str]] = []
|
||||
|
||||
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
|
||||
for path in all_tag_paths:
|
||||
existing_set = existing_tags.get(path, set())
|
||||
new_set = new_tags.get(path, set())
|
||||
to_add = new_set - existing_set
|
||||
to_remove = existing_set - new_set
|
||||
|
||||
for tag in to_add:
|
||||
tag_inserts.append((model_type, path, tag))
|
||||
for tag in to_remove:
|
||||
tag_deletes.append((model_type, path, tag))
|
||||
|
||||
if tag_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
|
||||
tag_deletes,
|
||||
)
|
||||
if tag_inserts:
|
||||
conn.executemany(
|
||||
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
|
||||
tag_inserts,
|
||||
)
|
||||
|
||||
existing_hash_rows = conn.execute(
|
||||
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_hash_map: Dict[str, set] = {}
|
||||
for row in existing_hash_rows:
|
||||
sha_value = (row["sha256"] or "").lower()
|
||||
if not sha_value:
|
||||
continue
|
||||
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
|
||||
|
||||
new_hash_map: Dict[str, set] = {}
|
||||
for sha_value, paths in hash_index.items():
|
||||
normalized_sha = (sha_value or "").lower()
|
||||
if not normalized_sha:
|
||||
continue
|
||||
bucket = new_hash_map.setdefault(normalized_sha, set())
|
||||
for path in paths:
|
||||
if path:
|
||||
bucket.add(path)
|
||||
|
||||
hash_inserts: List[Tuple[str, str, str]] = []
|
||||
hash_deletes: List[Tuple[str, str, str]] = []
|
||||
|
||||
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
|
||||
for sha_value in all_shas:
|
||||
existing_paths = existing_hash_map.get(sha_value, set())
|
||||
new_paths = new_hash_map.get(sha_value, set())
|
||||
|
||||
for path in existing_paths - new_paths:
|
||||
hash_deletes.append((model_type, sha_value, path))
|
||||
for path in new_paths - existing_paths:
|
||||
hash_inserts.append((model_type, sha_value, path))
|
||||
|
||||
if hash_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
|
||||
hash_deletes,
|
||||
)
|
||||
if hash_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
|
||||
hash_inserts,
|
||||
)
|
||||
|
||||
existing_excluded_rows = conn.execute(
|
||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
|
||||
new_excluded = {path for path in excluded_models if path}
|
||||
|
||||
excluded_deletes = [
|
||||
(model_type, path)
|
||||
for path in existing_excluded - new_excluded
|
||||
]
|
||||
excluded_inserts = [
|
||||
(model_type, path)
|
||||
for path in new_excluded - existing_excluded
|
||||
]
|
||||
|
||||
if excluded_deletes:
|
||||
conn.executemany(
|
||||
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
|
||||
excluded_deletes,
|
||||
)
|
||||
if excluded_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
|
||||
excluded_inserts,
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist cache for %s: %s", model_type, exc)
|
||||
|
||||
# Internal helpers -------------------------------------------------
|
||||
|
||||
def _resolve_default_path(self, library_name: str) -> str:
|
||||
override = os.environ.get("LORA_MANAGER_CACHE_DB")
|
||||
if override:
|
||||
return override
|
||||
try:
|
||||
settings_dir = get_settings_dir(create=True)
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Falling back to project directory for cache: %s", exc)
|
||||
settings_dir = get_project_root()
|
||||
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", library_name or "default")
|
||||
if safe_name.lower() in ("default", ""):
|
||||
legacy_path = os.path.join(settings_dir, self._DEFAULT_FILENAME)
|
||||
if os.path.exists(legacy_path):
|
||||
return legacy_path
|
||||
return os.path.join(settings_dir, "model_cache", f"{safe_name}.sqlite")
|
||||
|
||||
def _initialize_schema(self) -> None:
|
||||
with self._db_lock:
|
||||
if self._schema_initialized:
|
||||
return
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
model_type TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
model_name TEXT,
|
||||
folder TEXT,
|
||||
size INTEGER,
|
||||
modified REAL,
|
||||
sha256 TEXT,
|
||||
base_model TEXT,
|
||||
preview_url TEXT,
|
||||
preview_nsfw_level INTEGER,
|
||||
from_civitai INTEGER,
|
||||
favorite INTEGER,
|
||||
notes TEXT,
|
||||
usage_tips TEXT,
|
||||
metadata_source TEXT,
|
||||
civitai_id INTEGER,
|
||||
civitai_model_id INTEGER,
|
||||
civitai_name TEXT,
|
||||
civitai_creator_username TEXT,
|
||||
trained_words TEXT,
|
||||
civitai_deleted INTEGER,
|
||||
exclude INTEGER,
|
||||
db_checked INTEGER,
|
||||
last_checked_at REAL,
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_tags (
|
||||
model_type TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, file_path, tag)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hash_index (
|
||||
model_type TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, sha256, file_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS excluded_models (
|
||||
model_type TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_additional_model_columns(conn)
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.warning("Failed to initialize persistent cache schema: %s", exc)
|
||||
|
||||
def _ensure_additional_model_columns(self, conn: sqlite3.Connection) -> None:
|
||||
try:
|
||||
existing_columns = {
|
||||
row["name"]
|
||||
for row in conn.execute("PRAGMA table_info(models)").fetchall()
|
||||
}
|
||||
except Exception: # pragma: no cover - defensive guard
|
||||
return
|
||||
|
||||
required_columns = {
|
||||
"metadata_source": "TEXT",
|
||||
"civitai_creator_username": "TEXT",
|
||||
"civitai_deleted": "INTEGER DEFAULT 0",
|
||||
}
|
||||
|
||||
for column, definition in required_columns.items():
|
||||
if column not in existing_columns:
|
||||
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
uri = False
|
||||
path = self._db_path
|
||||
if readonly:
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
path = f"file:{path}?mode=ro"
|
||||
uri = True
|
||||
conn = sqlite3.connect(path, check_same_thread=False, uri=uri, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _prepare_model_row(self, model_type: str, item: Dict) -> Tuple:
|
||||
civitai = item.get("civitai") or {}
|
||||
trained_words = civitai.get("trainedWords")
|
||||
if isinstance(trained_words, str):
|
||||
trained_words_json = trained_words
|
||||
elif trained_words is None:
|
||||
trained_words_json = None
|
||||
else:
|
||||
trained_words_json = json.dumps(trained_words)
|
||||
|
||||
metadata_source = item.get("metadata_source") or None
|
||||
creator_username = None
|
||||
creator_data = civitai.get("creator") if isinstance(civitai, dict) else None
|
||||
if isinstance(creator_data, dict):
|
||||
creator_username = creator_data.get("username") or None
|
||||
|
||||
return (
|
||||
model_type,
|
||||
item.get("file_path"),
|
||||
item.get("file_name"),
|
||||
item.get("model_name"),
|
||||
item.get("folder"),
|
||||
int(item.get("size") or 0),
|
||||
float(item.get("modified") or 0.0),
|
||||
(item.get("sha256") or "").lower() or None,
|
||||
item.get("base_model"),
|
||||
item.get("preview_url"),
|
||||
int(item.get("preview_nsfw_level") or 0),
|
||||
1 if item.get("from_civitai", True) else 0,
|
||||
1 if item.get("favorite") else 0,
|
||||
item.get("notes"),
|
||||
item.get("usage_tips"),
|
||||
metadata_source,
|
||||
civitai.get("id"),
|
||||
civitai.get("modelId"),
|
||||
civitai.get("name"),
|
||||
creator_username,
|
||||
trained_words_json,
|
||||
1 if item.get("civitai_deleted") else 0,
|
||||
1 if item.get("exclude") else 0,
|
||||
1 if item.get("db_checked") else 0,
|
||||
float(item.get("last_checked_at") or 0.0),
|
||||
)
|
||||
|
||||
def _insert_model_sql(self) -> str:
|
||||
columns = ", ".join(self._MODEL_COLUMNS)
|
||||
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
|
||||
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
|
||||
|
||||
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
|
||||
tag_rows = conn.execute(
|
||||
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
result: Dict[str, List[str]] = {}
|
||||
for row in tag_rows:
|
||||
result.setdefault(row["file_path"], []).append(row["tag"])
|
||||
return result
|
||||
|
||||
|
||||
def get_persistent_cache() -> PersistentModelCache:
|
||||
from .settings_manager import get_settings_manager # Local import to avoid cycles
|
||||
|
||||
library_name = get_settings_manager().get_active_library_name()
|
||||
return PersistentModelCache.get_default(library_name)
|
||||
@@ -5,8 +5,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
from typing import Awaitable, Callable, Dict, Optional, Sequence
|
||||
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 select_preview_media
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,27 +45,74 @@ class PreviewAssetService:
|
||||
if not images:
|
||||
return
|
||||
|
||||
first_preview = images[0]
|
||||
settings_manager = get_settings_manager()
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
first_preview, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
)
|
||||
|
||||
if not first_preview:
|
||||
return
|
||||
|
||||
base_name = os.path.splitext(os.path.splitext(os.path.basename(metadata_path))[0])[0]
|
||||
preview_dir = os.path.dirname(metadata_path)
|
||||
is_video = first_preview.get("type") == "video"
|
||||
preview_url = first_preview.get("url")
|
||||
|
||||
if not preview_url:
|
||||
return
|
||||
|
||||
def extension_from_url(url: str, fallback: str) -> str:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except ValueError:
|
||||
return fallback
|
||||
ext = os.path.splitext(parsed.path)[1]
|
||||
return ext or fallback
|
||||
|
||||
downloader = await self._downloader_factory()
|
||||
|
||||
if is_video:
|
||||
extension = ".mp4"
|
||||
extension = extension_from_url(preview_url, ".mp4")
|
||||
preview_path = os.path.join(preview_dir, base_name + extension)
|
||||
downloader = await self._downloader_factory()
|
||||
success, result = await downloader.download_file(
|
||||
first_preview["url"], preview_path, use_auth=False
|
||||
)
|
||||
if success:
|
||||
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
|
||||
local_metadata["preview_nsfw_level"] = first_preview.get("nsfwLevel", 0)
|
||||
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type="video")
|
||||
|
||||
attempt_urls = []
|
||||
if rewritten:
|
||||
attempt_urls.append(rewritten_url)
|
||||
attempt_urls.append(preview_url)
|
||||
|
||||
seen: set[str] = set()
|
||||
for candidate in attempt_urls:
|
||||
if not candidate or candidate in seen:
|
||||
continue
|
||||
seen.add(candidate)
|
||||
|
||||
success, _ = await downloader.download_file(candidate, preview_path, use_auth=False)
|
||||
if success:
|
||||
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
|
||||
local_metadata["preview_nsfw_level"] = nsfw_level
|
||||
return
|
||||
else:
|
||||
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type="image")
|
||||
if rewritten:
|
||||
extension = extension_from_url(preview_url, ".png")
|
||||
preview_path = os.path.join(preview_dir, base_name + extension)
|
||||
success, _ = await downloader.download_file(
|
||||
rewritten_url, preview_path, use_auth=False
|
||||
)
|
||||
if success:
|
||||
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
|
||||
local_metadata["preview_nsfw_level"] = nsfw_level
|
||||
return
|
||||
|
||||
extension = ".webp"
|
||||
preview_path = os.path.join(preview_dir, base_name + extension)
|
||||
downloader = await self._downloader_factory()
|
||||
success, content, _headers = await downloader.download_to_memory(
|
||||
first_preview["url"], use_auth=False
|
||||
preview_url, use_auth=False
|
||||
)
|
||||
if not success:
|
||||
return
|
||||
@@ -86,7 +137,7 @@ class PreviewAssetService:
|
||||
return
|
||||
|
||||
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
|
||||
local_metadata["preview_nsfw_level"] = first_preview.get("nsfwLevel", 0)
|
||||
local_metadata["preview_nsfw_level"] = nsfw_level
|
||||
|
||||
async def replace_preview(
|
||||
self,
|
||||
|
||||
@@ -52,6 +52,31 @@ class RecipeScanner:
|
||||
if lora_scanner:
|
||||
self._lora_scanner = lora_scanner
|
||||
self._initialized = True
|
||||
|
||||
def on_library_changed(self) -> None:
|
||||
"""Reset cached state when the active library changes."""
|
||||
|
||||
# Cancel any in-flight initialization or resorting work so the next
|
||||
# access rebuilds the cache for the new library.
|
||||
if self._initialization_task and not self._initialization_task.done():
|
||||
self._initialization_task.cancel()
|
||||
|
||||
for task in list(self._resort_tasks):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
self._resort_tasks.clear()
|
||||
|
||||
self._cache = None
|
||||
self._initialization_task = None
|
||||
self._is_initializing = False
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and not loop.is_closed():
|
||||
loop.create_task(self.initialize_in_background())
|
||||
|
||||
async def _get_civitai_client(self):
|
||||
"""Lazily initialize CivitaiClient from registry"""
|
||||
@@ -359,16 +384,32 @@ class RecipeScanner:
|
||||
|
||||
# Ensure the image file exists
|
||||
image_path = recipe_data.get('file_path')
|
||||
if not os.path.exists(image_path):
|
||||
normalized_image_path = os.path.normpath(image_path) if image_path else image_path
|
||||
path_updated = False
|
||||
if image_path and normalized_image_path != image_path:
|
||||
recipe_data['file_path'] = normalized_image_path
|
||||
image_path = normalized_image_path
|
||||
path_updated = True
|
||||
|
||||
if image_path and not os.path.exists(image_path):
|
||||
logger.warning(f"Recipe image not found: {image_path}")
|
||||
# Try to find the image in the same directory as the recipe
|
||||
recipe_dir = os.path.dirname(recipe_path)
|
||||
image_filename = os.path.basename(image_path)
|
||||
alternative_path = os.path.join(recipe_dir, image_filename)
|
||||
if os.path.exists(alternative_path):
|
||||
recipe_data['file_path'] = alternative_path
|
||||
normalized_alternative = os.path.normpath(alternative_path)
|
||||
recipe_data['file_path'] = normalized_alternative
|
||||
image_path = normalized_alternative
|
||||
path_updated = True
|
||||
logger.info(
|
||||
"Updated recipe image path to %s after relocating asset", normalized_alternative
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Could not find alternative image path for {image_path}")
|
||||
|
||||
if path_updated:
|
||||
self._write_recipe_file(recipe_path, recipe_data)
|
||||
|
||||
# Ensure loras array exists
|
||||
if 'loras' not in recipe_data:
|
||||
@@ -388,18 +429,24 @@ class RecipeScanner:
|
||||
|
||||
# Write updated recipe data back to file
|
||||
try:
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
self._write_recipe_file(recipe_path, recipe_data)
|
||||
logger.info(f"Added fingerprint to recipe: {recipe_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing updated recipe with fingerprint: {e}")
|
||||
|
||||
|
||||
return recipe_data
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading recipe file {recipe_path}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _write_recipe_file(recipe_path: str, recipe_data: Dict[str, Any]) -> None:
|
||||
"""Persist ``recipe_data`` back to ``recipe_path`` with standard formatting."""
|
||||
|
||||
with open(recipe_path, 'w', encoding='utf-8') as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
async def _update_lora_information(self, recipe_data: Dict) -> bool:
|
||||
"""Update LoRA information with hash and file_name
|
||||
@@ -424,27 +471,29 @@ class RecipeScanner:
|
||||
# If has modelVersionId but no hash, look in lora cache first, then fetch from Civitai
|
||||
if 'modelVersionId' in lora and not lora.get('hash'):
|
||||
model_version_id = lora['modelVersionId']
|
||||
# Check if model_version_id is an integer and > 0
|
||||
if isinstance(model_version_id, int) and model_version_id > 0:
|
||||
|
||||
# Try to find in lora cache first
|
||||
hash_from_cache = await self._find_hash_in_lora_cache(model_version_id)
|
||||
if hash_from_cache:
|
||||
lora['hash'] = hash_from_cache
|
||||
metadata_updated = True
|
||||
else:
|
||||
# If not in cache, fetch from Civitai
|
||||
result = await self._get_hash_from_civitai(model_version_id)
|
||||
if isinstance(result, tuple):
|
||||
hash_from_civitai, is_deleted = result
|
||||
if hash_from_civitai:
|
||||
lora['hash'] = hash_from_civitai
|
||||
metadata_updated = True
|
||||
elif is_deleted:
|
||||
# Mark the lora as deleted if it was not found on Civitai
|
||||
lora['isDeleted'] = True
|
||||
logger.warning(f"Marked lora with modelVersionId {model_version_id} as deleted")
|
||||
metadata_updated = True
|
||||
# Try to find in lora cache first
|
||||
hash_from_cache = await self._find_hash_in_lora_cache(model_version_id)
|
||||
if hash_from_cache:
|
||||
lora['hash'] = hash_from_cache
|
||||
metadata_updated = True
|
||||
else:
|
||||
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
|
||||
# If not in cache, fetch from Civitai
|
||||
result = await self._get_hash_from_civitai(model_version_id)
|
||||
if isinstance(result, tuple):
|
||||
hash_from_civitai, is_deleted = result
|
||||
if hash_from_civitai:
|
||||
lora['hash'] = hash_from_civitai
|
||||
metadata_updated = True
|
||||
elif is_deleted:
|
||||
# Mark the lora as deleted if it was not found on Civitai
|
||||
lora['isDeleted'] = True
|
||||
logger.warning(f"Marked lora with modelVersionId {model_version_id} as deleted")
|
||||
metadata_updated = True
|
||||
else:
|
||||
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
|
||||
|
||||
# If has hash but no file_name, look up in lora library
|
||||
if 'hash' in lora and (not lora.get('file_name') or not lora['file_name']):
|
||||
@@ -598,6 +647,17 @@ class RecipeScanner:
|
||||
# Get base dataset
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# Apply SFW filtering if enabled
|
||||
from .settings_manager import get_settings_manager
|
||||
settings = get_settings_manager()
|
||||
if settings.get("show_only_sfw", False):
|
||||
from ..utils.constants import NSFW_LEVELS
|
||||
threshold = NSFW_LEVELS.get("R", 4) # Default to R level (4) if not found
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold
|
||||
]
|
||||
|
||||
# Special case: Filter by LoRA hash (takes precedence if bypass_filters is True)
|
||||
if lora_hash:
|
||||
# Filter recipes that contain this LoRA hash
|
||||
@@ -740,20 +800,17 @@ class RecipeScanner:
|
||||
"""Format file path as URL for serving in web UI"""
|
||||
if not file_path:
|
||||
return '/loras_static/images/no-preview.png'
|
||||
|
||||
|
||||
try:
|
||||
# Format file path as a URL that will work with static file serving
|
||||
recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, '/')
|
||||
if file_path.replace(os.sep, '/').startswith(recipes_dir):
|
||||
relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, '/')
|
||||
return f"/loras_static/root1/preview/{relative_path}"
|
||||
|
||||
# If not in recipes dir, try to create a valid URL from the file name
|
||||
file_name = os.path.basename(file_path)
|
||||
return f"/loras_static/root1/preview/recipes/{file_name}"
|
||||
normalized_path = os.path.normpath(file_path)
|
||||
static_url = config.get_preview_static_url(normalized_path)
|
||||
if static_url:
|
||||
return static_url
|
||||
except Exception as e:
|
||||
logger.error(f"Error formatting file URL: {e}")
|
||||
return '/loras_static/images/no-preview.png'
|
||||
|
||||
return '/loras_static/images/no-preview.png'
|
||||
|
||||
def _format_timestamp(self, timestamp: float) -> str:
|
||||
"""Format timestamp for display"""
|
||||
|
||||
@@ -73,7 +73,8 @@ class RecipePersistenceService:
|
||||
)
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
image_path = os.path.join(recipes_dir, image_filename)
|
||||
with open(image_path, "wb") as file_obj:
|
||||
normalized_image_path = os.path.normpath(image_path)
|
||||
with open(normalized_image_path, "wb") as file_obj:
|
||||
file_obj.write(optimized_image)
|
||||
|
||||
current_time = time.time()
|
||||
@@ -97,7 +98,7 @@ class RecipePersistenceService:
|
||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||
recipe_data: Dict[str, Any] = {
|
||||
"id": recipe_id,
|
||||
"file_path": image_path,
|
||||
"file_path": normalized_image_path,
|
||||
"title": name,
|
||||
"modified": current_time,
|
||||
"created_date": current_time,
|
||||
@@ -116,10 +117,11 @@ class RecipePersistenceService:
|
||||
|
||||
json_filename = f"{recipe_id}.recipe.json"
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
json_path = os.path.normpath(json_path)
|
||||
with open(json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
||||
|
||||
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
||||
await recipe_scanner.add_recipe(recipe_data)
|
||||
@@ -128,7 +130,7 @@ class RecipePersistenceService:
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"image_path": image_path,
|
||||
"image_path": normalized_image_path,
|
||||
"json_path": json_path,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
@@ -279,10 +281,17 @@ class RecipePersistenceService:
|
||||
os.makedirs(recipes_dir, exist_ok=True)
|
||||
|
||||
recipe_id = str(uuid.uuid4())
|
||||
image_filename = f"{recipe_id}.png"
|
||||
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,
|
||||
)
|
||||
image_filename = f"{recipe_id}{extension}"
|
||||
image_path = os.path.join(recipes_dir, image_filename)
|
||||
with open(image_path, "wb") as file_obj:
|
||||
file_obj.write(image_bytes)
|
||||
file_obj.write(optimized_image)
|
||||
|
||||
lora_stack = metadata.get("loras", "")
|
||||
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", lora_stack)
|
||||
@@ -298,9 +307,9 @@ class RecipePersistenceService:
|
||||
"file_name": name,
|
||||
"strength": float(strength),
|
||||
"hash": (lora_info.get("sha256") or "").lower() if lora_info else "",
|
||||
"modelVersionId": lora_info.get("civitai", {}).get("id") if lora_info else 0,
|
||||
"modelName": lora_info.get("civitai", {}).get("model", {}).get("name") if lora_info else "",
|
||||
"modelVersionName": lora_info.get("civitai", {}).get("name") if lora_info else "",
|
||||
"modelVersionId": (lora_info.get("civitai") or {}).get("id", 0) if lora_info else 0,
|
||||
"modelName": ((lora_info.get("civitai") or {}).get("model") or {}).get("name", name) if lora_info else "",
|
||||
"modelVersionName": (lora_info.get("civitai") or {}).get("name", "") if lora_info else "",
|
||||
"isDeleted": False,
|
||||
"exclude": False,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
|
||||
@@ -59,9 +61,10 @@ class RecipeSharingService:
|
||||
}
|
||||
self._cleanup_shared_recipes()
|
||||
|
||||
safe_title = recipe.get("title", "").replace(" ", "_").lower()
|
||||
filename = f"recipe_{safe_title}{ext}" if safe_title else f"recipe_{recipe_id}{ext}"
|
||||
url_path = f"/api/recipe/{recipe_id}/share/download?t={timestamp}"
|
||||
filename = self._build_download_filename(
|
||||
title=recipe.get("title", ""), recipe_id=recipe_id, ext=ext
|
||||
)
|
||||
url_path = f"/api/lm/recipe/{recipe_id}/share/download?t={timestamp}"
|
||||
return SharingResult({"success": True, "download_url": url_path, "filename": filename})
|
||||
|
||||
async def prepare_download(self, *, recipe_scanner, recipe_id: str) -> DownloadInfo:
|
||||
@@ -78,13 +81,38 @@ class RecipeSharingService:
|
||||
raise RecipeNotFoundError("Shared recipe file not found")
|
||||
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
filename_base = (
|
||||
f"recipe_{recipe.get('title', '').replace(' ', '_').lower()}" if recipe else recipe_id
|
||||
)
|
||||
ext = os.path.splitext(file_path)[1]
|
||||
download_filename = f"{filename_base}{ext}"
|
||||
download_filename = self._build_download_filename(
|
||||
title=recipe.get("title", "") if recipe else "",
|
||||
recipe_id=recipe_id,
|
||||
ext=ext,
|
||||
)
|
||||
return DownloadInfo(file_path=file_path, download_filename=download_filename)
|
||||
|
||||
@staticmethod
|
||||
def _build_download_filename(*, title: str, recipe_id: str, ext: str) -> str:
|
||||
"""Generate a sanitized filename safe for HTTP headers and filesystems."""
|
||||
|
||||
ext = ext or ""
|
||||
safe_title = RecipeSharingService._slugify(title)
|
||||
fallback = RecipeSharingService._slugify(recipe_id)
|
||||
identifier = safe_title or fallback or "recipe"
|
||||
return f"recipe_{identifier}{ext}"
|
||||
|
||||
@staticmethod
|
||||
def _slugify(value: str) -> str:
|
||||
"""Convert arbitrary input into a lowercase, header-safe slug."""
|
||||
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
ascii_value = ascii_value.replace("\n", " ").replace("\r", " ")
|
||||
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", ascii_value)
|
||||
sanitized = re.sub(r"_+", "_", sanitized).strip("._-")
|
||||
return sanitized.lower()
|
||||
|
||||
def _cleanup_shared_recipes(self) -> None:
|
||||
for recipe_id in list(self._shared_recipes.keys()):
|
||||
shared = self._shared_recipes.get(recipe_id)
|
||||
|
||||
@@ -128,6 +128,49 @@ class ServiceRegistry:
|
||||
async def get_civitai_client(cls):
|
||||
"""Get or create CivitAI client instance"""
|
||||
service_name = "civitai_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 .civitai_client import CivitaiClient
|
||||
|
||||
client = await CivitaiClient.get_instance()
|
||||
cls._services[service_name] = client
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return client
|
||||
|
||||
@classmethod
|
||||
async def get_model_update_service(cls):
|
||||
"""Get or create the model update tracking service."""
|
||||
|
||||
service_name = "model_update_service"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
from .model_update_service import ModelUpdateService
|
||||
from .persistent_model_cache import get_persistent_cache
|
||||
|
||||
cache = get_persistent_cache()
|
||||
service = ModelUpdateService(cache.get_database_path())
|
||||
cls._services[service_name] = service
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
async def get_civarchive_client(cls):
|
||||
"""Get or create CivArchive client instance"""
|
||||
service_name = "civarchive_client"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
@@ -138,9 +181,9 @@ class ServiceRegistry:
|
||||
return cls._services[service_name]
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from .civitai_client import CivitaiClient
|
||||
from .civarchive_client import CivArchiveClient
|
||||
|
||||
client = await CivitaiClient.get_instance()
|
||||
client = await CivArchiveClient.get_instance()
|
||||
cls._services[service_name] = client
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return client
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Any, Dict, Optional, Protocol, Sequence
|
||||
|
||||
from ..metadata_sync_service import MetadataSyncService
|
||||
from ...utils.metadata_manager import MetadataManager
|
||||
|
||||
|
||||
class MetadataRefreshProgressReporter(Protocol):
|
||||
@@ -70,6 +71,7 @@ class BulkMetadataRefreshUseCase:
|
||||
for model in to_process:
|
||||
try:
|
||||
original_name = model.get("model_name")
|
||||
await MetadataManager.hydrate_model_data(model)
|
||||
result, _ = await self._metadata_sync.fetch_and_update_model(
|
||||
sha256=model["sha256"],
|
||||
file_path=model["file_path"],
|
||||
|
||||
@@ -16,6 +16,8 @@ class WebSocketManager:
|
||||
self._download_websockets: Dict[str, web.WebSocketResponse] = {} # New dict for download-specific clients
|
||||
# Add progress tracking dictionary
|
||||
self._download_progress: Dict[str, Dict] = {}
|
||||
# Cache last initialization progress payloads
|
||||
self._last_init_progress: Dict[str, Dict] = {}
|
||||
# Add auto-organize progress tracking
|
||||
self._auto_organize_progress: Optional[Dict] = None
|
||||
self._auto_organize_lock = asyncio.Lock()
|
||||
@@ -39,8 +41,10 @@ class WebSocketManager:
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self._init_websockets.add(ws)
|
||||
|
||||
|
||||
try:
|
||||
await self._send_cached_init_progress(ws)
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.ERROR:
|
||||
logger.error(f'Init WebSocket error: {ws.exception()}')
|
||||
@@ -102,30 +106,70 @@ class WebSocketManager:
|
||||
|
||||
async def broadcast_init_progress(self, data: Dict):
|
||||
"""Broadcast initialization progress to connected clients"""
|
||||
payload = dict(data) if data else {}
|
||||
|
||||
if 'stage' not in payload:
|
||||
payload['stage'] = 'processing'
|
||||
if 'progress' not in payload:
|
||||
payload['progress'] = 0
|
||||
if 'details' not in payload:
|
||||
payload['details'] = 'Processing...'
|
||||
|
||||
key = self._get_init_progress_key(payload)
|
||||
self._last_init_progress[key] = dict(payload)
|
||||
|
||||
if not self._init_websockets:
|
||||
return
|
||||
|
||||
# Ensure data has all required fields
|
||||
if 'stage' not in data:
|
||||
data['stage'] = 'processing'
|
||||
if 'progress' not in data:
|
||||
data['progress'] = 0
|
||||
if 'details' not in data:
|
||||
data['details'] = 'Processing...'
|
||||
|
||||
for ws in self._init_websockets:
|
||||
|
||||
stale_clients = []
|
||||
for ws in list(self._init_websockets):
|
||||
try:
|
||||
await ws.send_json(data)
|
||||
await ws.send_json(payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending initialization progress: {e}")
|
||||
|
||||
stale_clients.append(ws)
|
||||
|
||||
for ws in stale_clients:
|
||||
self._init_websockets.discard(ws)
|
||||
|
||||
async def _send_cached_init_progress(self, ws: web.WebSocketResponse) -> None:
|
||||
"""Send cached initialization progress payloads to a new client"""
|
||||
if not self._last_init_progress:
|
||||
return
|
||||
|
||||
for payload in list(self._last_init_progress.values()):
|
||||
try:
|
||||
await ws.send_json(payload)
|
||||
except Exception as e:
|
||||
logger.debug(f'Error sending cached initialization progress: {e}')
|
||||
|
||||
def _get_init_progress_key(self, data: Dict) -> str:
|
||||
"""Return a stable key for caching initialization progress payloads"""
|
||||
page_type = data.get('pageType')
|
||||
if page_type:
|
||||
return f'page:{page_type}'
|
||||
scanner_type = data.get('scanner_type')
|
||||
if scanner_type:
|
||||
return f'scanner:{scanner_type}'
|
||||
return 'global'
|
||||
|
||||
async def broadcast_download_progress(self, download_id: str, data: Dict):
|
||||
"""Send progress update to specific download client"""
|
||||
# Store simplified progress data in memory (only progress percentage)
|
||||
self._download_progress[download_id] = {
|
||||
progress_entry = {
|
||||
'progress': data.get('progress', 0),
|
||||
'timestamp': datetime.now()
|
||||
'timestamp': datetime.now(),
|
||||
}
|
||||
|
||||
for field in ('bytes_downloaded', 'total_bytes', 'bytes_per_second'):
|
||||
if field in data:
|
||||
progress_entry[field] = data[field]
|
||||
|
||||
if 'status' in data:
|
||||
progress_entry['status'] = data['status']
|
||||
if 'message' in data:
|
||||
progress_entry['message'] = data['message']
|
||||
|
||||
self._download_progress[download_id] = progress_entry
|
||||
|
||||
if download_id not in self._download_websockets:
|
||||
logger.debug(f"No WebSocket found for download ID: {download_id}")
|
||||
@@ -202,4 +246,5 @@ class WebSocketManager:
|
||||
return str(uuid4())
|
||||
|
||||
# Global instance
|
||||
ws_manager = WebSocketManager()
|
||||
ws_manager = WebSocketManager()
|
||||
|
||||
|
||||
47
py/utils/civitai_utils.py
Normal file
47
py/utils/civitai_utils.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Utilities for working with Civitai assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -> tuple[str | None, bool]:
|
||||
"""Rewrite Civitai preview URLs to use optimized renditions.
|
||||
|
||||
Args:
|
||||
source_url: Original preview URL from the Civitai API.
|
||||
media_type: Optional media type hint (e.g. ``"image"`` or ``"video"``).
|
||||
|
||||
Returns:
|
||||
A tuple of the potentially rewritten URL and a flag indicating whether the
|
||||
replacement occurred. When the URL is not rewritten, the original value is
|
||||
returned with ``False``.
|
||||
"""
|
||||
if not source_url:
|
||||
return source_url, False
|
||||
|
||||
try:
|
||||
parsed = urlparse(source_url)
|
||||
except ValueError:
|
||||
return source_url, False
|
||||
|
||||
if parsed.netloc.lower() != "image.civitai.com":
|
||||
return source_url, False
|
||||
|
||||
replacement = "/width=450,optimized=true"
|
||||
if (media_type or "").lower() == "video":
|
||||
replacement = "/transcode=true,width=450,optimized=true"
|
||||
|
||||
if "/original=true" not in parsed.path:
|
||||
return source_url, False
|
||||
|
||||
updated_path = parsed.path.replace("/original=true", replacement, 1)
|
||||
if updated_path == parsed.path:
|
||||
return source_url, False
|
||||
|
||||
rewritten = urlunparse(parsed._replace(path=updated_path))
|
||||
return rewritten, True
|
||||
|
||||
|
||||
__all__ = ["rewrite_preview_url"]
|
||||
|
||||
@@ -48,6 +48,13 @@ SUPPORTED_MEDIA_EXTENSIONS = {
|
||||
# Valid Lora types
|
||||
VALID_LORA_TYPES = ['lora', 'locon', 'dora']
|
||||
|
||||
# Supported Civitai model types for user model queries (case-insensitive)
|
||||
CIVITAI_USER_MODEL_TYPES = [
|
||||
*VALID_LORA_TYPES,
|
||||
'textualinversion',
|
||||
'checkpoint',
|
||||
]
|
||||
|
||||
# Auto-organize settings
|
||||
AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming the system
|
||||
|
||||
@@ -57,4 +64,11 @@ CIVITAI_MODEL_TAGS = [
|
||||
'realistic', 'anime', 'toon', 'furry', 'style',
|
||||
'poses', 'background', 'tool', 'vehicle', 'buildings',
|
||||
'objects', 'assets', 'animal', 'action'
|
||||
]
|
||||
]
|
||||
|
||||
# Default priority tag configuration strings for each model type
|
||||
DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||
'lora': ', '.join(CIVITAI_MODEL_TAGS),
|
||||
'checkpoint': ', '.join(CIVITAI_MODEL_TAGS),
|
||||
'embedding': ', '.join(CIVITAI_MODEL_TAGS),
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any, Dict, Iterable, List, Set, Tuple
|
||||
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.example_images_paths import (
|
||||
ExampleImagePathResolver,
|
||||
ensure_library_root_exists,
|
||||
uses_library_scoped_folders,
|
||||
)
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .example_images_processor import ExampleImagesProcessor
|
||||
from .example_images_metadata import MetadataUpdater
|
||||
from ..services.downloader import get_downloader
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
|
||||
|
||||
class ExampleImagesDownloadError(RuntimeError):
|
||||
@@ -74,6 +82,22 @@ class _DownloadProgress(dict):
|
||||
snapshot['failed_models'] = list(self['failed_models'])
|
||||
return snapshot
|
||||
|
||||
|
||||
def _model_directory_has_files(path: str) -> bool:
|
||||
"""Return True when the provided directory exists and contains entries."""
|
||||
|
||||
if not path or not os.path.isdir(path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with os.scandir(path) as entries:
|
||||
for _ in entries:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
class DownloadManager:
|
||||
"""Manages downloading example images for models."""
|
||||
|
||||
@@ -83,6 +107,13 @@ class DownloadManager:
|
||||
self._progress = _DownloadProgress()
|
||||
self._ws_manager = ws_manager
|
||||
self._state_lock = state_lock or asyncio.Lock()
|
||||
self._stop_requested = False
|
||||
|
||||
def _resolve_output_dir(self, library_name: str | None = None) -> str:
|
||||
base_path = get_settings_manager().get('example_images_path')
|
||||
if not base_path:
|
||||
return ''
|
||||
return ensure_library_root_exists(library_name)
|
||||
|
||||
async def start_download(self, options: dict):
|
||||
"""Start downloading example images for models."""
|
||||
@@ -98,9 +129,10 @@ class DownloadManager:
|
||||
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
||||
delay = float(data.get('delay', 0.2))
|
||||
|
||||
output_dir = settings.get('example_images_path')
|
||||
settings_manager = get_settings_manager()
|
||||
base_path = settings_manager.get('example_images_path')
|
||||
|
||||
if not output_dir:
|
||||
if not base_path:
|
||||
error_msg = 'Example images path not configured in settings'
|
||||
if auto_mode:
|
||||
logger.debug(error_msg)
|
||||
@@ -110,17 +142,43 @@ class DownloadManager:
|
||||
}
|
||||
raise DownloadConfigurationError(error_msg)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
active_library = get_settings_manager().get_active_library_name()
|
||||
output_dir = self._resolve_output_dir(active_library)
|
||||
if not output_dir:
|
||||
raise DownloadConfigurationError('Example images path not configured in settings')
|
||||
|
||||
self._progress.reset()
|
||||
self._stop_requested = False
|
||||
self._progress['status'] = 'running'
|
||||
self._progress['start_time'] = time.time()
|
||||
self._progress['end_time'] = None
|
||||
|
||||
progress_file = os.path.join(output_dir, '.download_progress.json')
|
||||
if os.path.exists(progress_file):
|
||||
progress_source = progress_file
|
||||
if uses_library_scoped_folders():
|
||||
legacy_root = get_settings_manager().get('example_images_path') or ''
|
||||
legacy_progress = os.path.join(legacy_root, '.download_progress.json') if legacy_root else ''
|
||||
if legacy_progress and os.path.exists(legacy_progress) and not os.path.exists(progress_file):
|
||||
try:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
shutil.move(legacy_progress, progress_file)
|
||||
logger.info(
|
||||
"Migrated legacy download progress file '%s' to '%s'",
|
||||
legacy_progress,
|
||||
progress_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to migrate download progress file from '%s' to '%s': %s",
|
||||
legacy_progress,
|
||||
progress_file,
|
||||
exc,
|
||||
)
|
||||
progress_source = legacy_progress
|
||||
|
||||
if os.path.exists(progress_source):
|
||||
try:
|
||||
with open(progress_file, 'r', encoding='utf-8') as f:
|
||||
with open(progress_source, 'r', encoding='utf-8') as f:
|
||||
saved_progress = json.load(f)
|
||||
self._progress['processed_models'] = set(saved_progress.get('processed_models', []))
|
||||
self._progress['failed_models'] = set(saved_progress.get('failed_models', []))
|
||||
@@ -143,11 +201,17 @@ class DownloadManager:
|
||||
output_dir,
|
||||
optimize,
|
||||
model_types,
|
||||
delay
|
||||
delay,
|
||||
active_library,
|
||||
)
|
||||
)
|
||||
|
||||
snapshot = self._progress.snapshot()
|
||||
except ExampleImagesDownloadError:
|
||||
# Re-raise our own exception types without wrapping
|
||||
self._is_downloading = False
|
||||
self._download_task = None
|
||||
raise
|
||||
except Exception as e:
|
||||
self._is_downloading = False
|
||||
self._download_task = None
|
||||
@@ -207,8 +271,36 @@ class DownloadManager:
|
||||
'success': True,
|
||||
'message': 'Download resumed'
|
||||
}
|
||||
|
||||
async def stop_download(self, request):
|
||||
"""Stop the example images download after the current model completes."""
|
||||
|
||||
async with self._state_lock:
|
||||
if not self._is_downloading:
|
||||
raise DownloadNotRunningError()
|
||||
|
||||
if self._progress['status'] in {'completed', 'error', 'stopped'}:
|
||||
raise DownloadNotRunningError()
|
||||
|
||||
if self._progress['status'] != 'stopping':
|
||||
self._stop_requested = True
|
||||
self._progress['status'] = 'stopping'
|
||||
|
||||
await self._broadcast_progress(status='stopping')
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Download stopping'
|
||||
}
|
||||
|
||||
async def _download_all_example_images(self, output_dir, optimize, model_types, delay):
|
||||
async def _download_all_example_images(
|
||||
self,
|
||||
output_dir,
|
||||
optimize,
|
||||
model_types,
|
||||
delay,
|
||||
library_name,
|
||||
):
|
||||
"""Download example images for all models."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
@@ -244,29 +336,75 @@ class DownloadManager:
|
||||
|
||||
# Process each model
|
||||
for i, (scanner_type, model, scanner) in enumerate(all_models):
|
||||
async with self._state_lock:
|
||||
current_status = self._progress['status']
|
||||
|
||||
if current_status not in {'running', 'paused', 'stopping'}:
|
||||
break
|
||||
|
||||
# Main logic for processing model is here, but actual operations are delegated to other classes
|
||||
was_remote_download = await self._process_model(
|
||||
scanner_type, model, scanner,
|
||||
output_dir, optimize, downloader
|
||||
scanner_type,
|
||||
model,
|
||||
scanner,
|
||||
output_dir,
|
||||
optimize,
|
||||
downloader,
|
||||
library_name,
|
||||
)
|
||||
|
||||
|
||||
# Update progress
|
||||
self._progress['completed'] += 1
|
||||
await self._broadcast_progress(status='running')
|
||||
|
||||
|
||||
async with self._state_lock:
|
||||
current_status = self._progress['status']
|
||||
should_stop = self._stop_requested and current_status == 'stopping'
|
||||
|
||||
broadcast_status = 'running' if current_status == 'running' else current_status
|
||||
await self._broadcast_progress(status=broadcast_status)
|
||||
|
||||
if should_stop:
|
||||
break
|
||||
|
||||
# Only add delay after remote download of models, and not after processing the last model
|
||||
if was_remote_download and i < len(all_models) - 1 and self._progress['status'] == 'running':
|
||||
if (
|
||||
was_remote_download
|
||||
and i < len(all_models) - 1
|
||||
and current_status == 'running'
|
||||
):
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Mark as completed
|
||||
self._progress['status'] = 'completed'
|
||||
self._progress['end_time'] = time.time()
|
||||
logger.debug(
|
||||
"Example images download completed: %s/%s models processed",
|
||||
self._progress['completed'],
|
||||
self._progress['total'],
|
||||
)
|
||||
await self._broadcast_progress(status='completed')
|
||||
|
||||
async with self._state_lock:
|
||||
if self._stop_requested and self._progress['status'] == 'stopping':
|
||||
self._progress['status'] = 'stopped'
|
||||
self._progress['end_time'] = time.time()
|
||||
self._stop_requested = False
|
||||
final_status = 'stopped'
|
||||
elif self._progress['status'] not in {'error', 'stopped'}:
|
||||
self._progress['status'] = 'completed'
|
||||
self._progress['end_time'] = time.time()
|
||||
self._stop_requested = False
|
||||
final_status = 'completed'
|
||||
else:
|
||||
final_status = self._progress['status']
|
||||
self._stop_requested = False
|
||||
if self._progress['end_time'] is None:
|
||||
self._progress['end_time'] = time.time()
|
||||
|
||||
if final_status == 'completed':
|
||||
logger.debug(
|
||||
"Example images download completed: %s/%s models processed",
|
||||
self._progress['completed'],
|
||||
self._progress['total'],
|
||||
)
|
||||
elif final_status == 'stopped':
|
||||
logger.debug(
|
||||
"Example images download stopped: %s/%s models processed",
|
||||
self._progress['completed'],
|
||||
self._progress['total'],
|
||||
)
|
||||
|
||||
await self._broadcast_progress(status=final_status)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error during example images download: {str(e)}"
|
||||
@@ -288,8 +426,18 @@ class DownloadManager:
|
||||
async with self._state_lock:
|
||||
self._is_downloading = False
|
||||
self._download_task = None
|
||||
self._stop_requested = False
|
||||
|
||||
async def _process_model(self, scanner_type, model, scanner, output_dir, optimize, downloader):
|
||||
async def _process_model(
|
||||
self,
|
||||
scanner_type,
|
||||
model,
|
||||
scanner,
|
||||
output_dir,
|
||||
optimize,
|
||||
downloader,
|
||||
library_name,
|
||||
):
|
||||
"""Process a single model download."""
|
||||
|
||||
# Check if download is paused
|
||||
@@ -297,7 +445,7 @@ class DownloadManager:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check if download should continue
|
||||
if self._progress['status'] != 'running':
|
||||
if self._progress['status'] not in {'running', 'stopping'}:
|
||||
logger.info(f"Download stopped: {self._progress['status']}")
|
||||
return False # Return False to indicate no remote download happened
|
||||
|
||||
@@ -316,20 +464,35 @@ class DownloadManager:
|
||||
logger.debug(f"Skipping known failed model: {model_name}")
|
||||
return False
|
||||
|
||||
model_dir = ExampleImagePathResolver.get_model_folder(model_hash, library_name)
|
||||
existing_files = _model_directory_has_files(model_dir)
|
||||
|
||||
# Skip if already processed AND directory exists with files
|
||||
if model_hash in self._progress['processed_models']:
|
||||
model_dir = os.path.join(output_dir, model_hash)
|
||||
has_files = os.path.exists(model_dir) and any(os.listdir(model_dir))
|
||||
if has_files:
|
||||
if existing_files:
|
||||
logger.debug(f"Skipping already processed model: {model_name}")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
|
||||
# Remove from processed models since we need to reprocess
|
||||
self._progress['processed_models'].discard(model_hash)
|
||||
|
||||
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
|
||||
# Remove from processed models since we need to reprocess
|
||||
self._progress['processed_models'].discard(model_hash)
|
||||
|
||||
if existing_files and model_hash not in self._progress['processed_models']:
|
||||
logger.debug(
|
||||
"Model folder already populated for %s, marking as processed without download",
|
||||
model_name,
|
||||
)
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
return False
|
||||
|
||||
if not model_dir:
|
||||
logger.warning(
|
||||
"Unable to resolve example images folder for model %s (%s)",
|
||||
model_name,
|
||||
model_hash,
|
||||
)
|
||||
return False
|
||||
|
||||
# Create model directory
|
||||
model_dir = os.path.join(output_dir, model_hash)
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
|
||||
# First check for local example images - local processing doesn't need delay
|
||||
@@ -345,14 +508,22 @@ class DownloadManager:
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
return False # Return False to indicate no remote download happened
|
||||
|
||||
full_model = await MetadataUpdater.get_updated_model(
|
||||
model_hash, scanner
|
||||
)
|
||||
civitai_payload = (full_model or {}).get('civitai') if full_model else None
|
||||
civitai_payload = civitai_payload or {}
|
||||
|
||||
# If no local images, try to download from remote
|
||||
elif model.get('civitai') and model.get('civitai', {}).get('images'):
|
||||
images = model.get('civitai', {}).get('images', [])
|
||||
|
||||
success, is_stale = await ExampleImagesProcessor.download_model_images(
|
||||
if civitai_payload.get('images'):
|
||||
images = civitai_payload.get('images', [])
|
||||
|
||||
success, is_stale, failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
|
||||
failed_urls: Set[str] = set(failed_images)
|
||||
|
||||
# If metadata is stale, try to refresh it
|
||||
if is_stale and model_hash not in self._progress['refreshed_models']:
|
||||
await MetadataUpdater.refresh_model_metadata(
|
||||
@@ -363,31 +534,49 @@ class DownloadManager:
|
||||
updated_model = await MetadataUpdater.get_updated_model(
|
||||
model_hash, scanner
|
||||
)
|
||||
updated_civitai = (updated_model or {}).get('civitai') if updated_model else None
|
||||
updated_civitai = updated_civitai or {}
|
||||
|
||||
if updated_model and updated_model.get('civitai', {}).get('images'):
|
||||
if updated_civitai.get('images'):
|
||||
# Retry download with updated metadata
|
||||
updated_images = updated_model.get('civitai', {}).get('images', [])
|
||||
success, _ = await ExampleImagesProcessor.download_model_images(
|
||||
updated_images = updated_civitai.get('images', [])
|
||||
success, _, additional_failed = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, updated_images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
failed_urls.update(additional_failed)
|
||||
|
||||
self._progress['refreshed_models'].add(model_hash)
|
||||
|
||||
# Mark as processed if successful, or as failed if unsuccessful after refresh
|
||||
if success:
|
||||
|
||||
if failed_urls:
|
||||
await self._remove_failed_images_from_metadata(
|
||||
model_hash,
|
||||
model_name,
|
||||
model_dir,
|
||||
failed_urls,
|
||||
scanner,
|
||||
)
|
||||
|
||||
if failed_urls:
|
||||
self._progress['failed_models'].add(model_hash)
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
logger.info(
|
||||
"Removed %s failed example images for %s", len(failed_urls), model_name
|
||||
)
|
||||
elif success:
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
else:
|
||||
# If we refreshed metadata and still failed, mark as permanently failed
|
||||
if model_hash in self._progress['refreshed_models']:
|
||||
self._progress['failed_models'].add(model_hash)
|
||||
logger.info(f"Marking model {model_name} as failed after metadata refresh")
|
||||
|
||||
self._progress['failed_models'].add(model_hash)
|
||||
logger.info(
|
||||
"Example images download failed for %s despite metadata refresh", model_name
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# Save progress periodically
|
||||
if self._progress['completed'] % 10 == 0 or self._progress['completed'] == self._progress['total'] - 1:
|
||||
self._save_progress(output_dir)
|
||||
@@ -452,14 +641,18 @@ class DownloadManager:
|
||||
if not model_hashes:
|
||||
raise DownloadConfigurationError('Missing model_hashes parameter')
|
||||
|
||||
output_dir = settings.get('example_images_path')
|
||||
settings_manager = get_settings_manager()
|
||||
base_path = settings_manager.get('example_images_path')
|
||||
|
||||
if not base_path:
|
||||
raise DownloadConfigurationError('Example images path not configured in settings')
|
||||
active_library = settings_manager.get_active_library_name()
|
||||
output_dir = self._resolve_output_dir(active_library)
|
||||
if not output_dir:
|
||||
raise DownloadConfigurationError('Example images path not configured in settings')
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
self._progress.reset()
|
||||
self._stop_requested = False
|
||||
self._progress['total'] = len(model_hashes)
|
||||
self._progress['status'] = 'running'
|
||||
self._progress['start_time'] = time.time()
|
||||
@@ -475,15 +668,21 @@ class DownloadManager:
|
||||
output_dir,
|
||||
optimize,
|
||||
model_types,
|
||||
delay
|
||||
delay,
|
||||
active_library,
|
||||
)
|
||||
|
||||
async with self._state_lock:
|
||||
self._is_downloading = False
|
||||
final_status = self._progress['status']
|
||||
|
||||
message = 'Force download completed'
|
||||
if final_status == 'stopped':
|
||||
message = 'Force download stopped'
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Force download completed',
|
||||
'message': message,
|
||||
'result': result
|
||||
}
|
||||
|
||||
@@ -494,7 +693,15 @@ class DownloadManager:
|
||||
await self._broadcast_progress(status='error', extra={'error': str(e)})
|
||||
raise ExampleImagesDownloadError(str(e)) from e
|
||||
|
||||
async def _download_specific_models_example_images_sync(self, model_hashes, output_dir, optimize, model_types, delay):
|
||||
async def _download_specific_models_example_images_sync(
|
||||
self,
|
||||
model_hashes,
|
||||
output_dir,
|
||||
optimize,
|
||||
model_types,
|
||||
delay,
|
||||
library_name,
|
||||
):
|
||||
"""Download example images for specific models only - synchronous version."""
|
||||
|
||||
downloader = await get_downloader()
|
||||
@@ -533,37 +740,81 @@ class DownloadManager:
|
||||
# Process each model
|
||||
success_count = 0
|
||||
for i, (scanner_type, model, scanner) in enumerate(models_to_process):
|
||||
async with self._state_lock:
|
||||
current_status = self._progress['status']
|
||||
|
||||
if current_status not in {'running', 'paused', 'stopping'}:
|
||||
break
|
||||
|
||||
# Force process this model regardless of previous status
|
||||
was_successful = await self._process_specific_model(
|
||||
scanner_type, model, scanner,
|
||||
output_dir, optimize, downloader
|
||||
scanner_type,
|
||||
model,
|
||||
scanner,
|
||||
output_dir,
|
||||
optimize,
|
||||
downloader,
|
||||
library_name,
|
||||
)
|
||||
|
||||
|
||||
if was_successful:
|
||||
success_count += 1
|
||||
|
||||
|
||||
# Update progress
|
||||
self._progress['completed'] += 1
|
||||
|
||||
async with self._state_lock:
|
||||
current_status = self._progress['status']
|
||||
should_stop = self._stop_requested and current_status == 'stopping'
|
||||
|
||||
broadcast_status = 'running' if current_status == 'running' else current_status
|
||||
# Send progress update via WebSocket
|
||||
await self._broadcast_progress(status='running')
|
||||
|
||||
await self._broadcast_progress(status=broadcast_status)
|
||||
|
||||
if should_stop:
|
||||
break
|
||||
|
||||
# Only add delay after remote download, and not after processing the last model
|
||||
if was_successful and i < len(models_to_process) - 1 and self._progress['status'] == 'running':
|
||||
if (
|
||||
was_successful
|
||||
and i < len(models_to_process) - 1
|
||||
and current_status == 'running'
|
||||
):
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Mark as completed
|
||||
self._progress['status'] = 'completed'
|
||||
self._progress['end_time'] = time.time()
|
||||
logger.debug(
|
||||
"Forced example images download completed: %s/%s models processed",
|
||||
self._progress['completed'],
|
||||
self._progress['total'],
|
||||
)
|
||||
|
||||
async with self._state_lock:
|
||||
if self._stop_requested and self._progress['status'] == 'stopping':
|
||||
self._progress['status'] = 'stopped'
|
||||
self._progress['end_time'] = time.time()
|
||||
self._stop_requested = False
|
||||
final_status = 'stopped'
|
||||
elif self._progress['status'] not in {'error', 'stopped'}:
|
||||
self._progress['status'] = 'completed'
|
||||
self._progress['end_time'] = time.time()
|
||||
self._stop_requested = False
|
||||
final_status = 'completed'
|
||||
else:
|
||||
final_status = self._progress['status']
|
||||
self._stop_requested = False
|
||||
if self._progress['end_time'] is None:
|
||||
self._progress['end_time'] = time.time()
|
||||
|
||||
if final_status == 'completed':
|
||||
logger.debug(
|
||||
"Forced example images download completed: %s/%s models processed",
|
||||
self._progress['completed'],
|
||||
self._progress['total'],
|
||||
)
|
||||
elif final_status == 'stopped':
|
||||
logger.debug(
|
||||
"Forced example images download stopped: %s/%s models processed",
|
||||
self._progress['completed'],
|
||||
self._progress['total'],
|
||||
)
|
||||
|
||||
# Send final progress via WebSocket
|
||||
await self._broadcast_progress(status='completed')
|
||||
|
||||
await self._broadcast_progress(status=final_status)
|
||||
|
||||
return {
|
||||
'total': self._progress['total'],
|
||||
'processed': self._progress['completed'],
|
||||
@@ -588,7 +839,16 @@ class DownloadManager:
|
||||
# No need to close any sessions since we use the global downloader
|
||||
pass
|
||||
|
||||
async def _process_specific_model(self, scanner_type, model, scanner, output_dir, optimize, downloader):
|
||||
async def _process_specific_model(
|
||||
self,
|
||||
scanner_type,
|
||||
model,
|
||||
scanner,
|
||||
output_dir,
|
||||
optimize,
|
||||
downloader,
|
||||
library_name,
|
||||
):
|
||||
"""Process a specific model for forced download, ignoring previous download status."""
|
||||
|
||||
# Check if download is paused
|
||||
@@ -596,7 +856,7 @@ class DownloadManager:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Check if download should continue
|
||||
if self._progress['status'] != 'running':
|
||||
if self._progress['status'] not in {'running', 'stopping'}:
|
||||
logger.info(f"Download stopped: {self._progress['status']}")
|
||||
return False
|
||||
|
||||
@@ -610,8 +870,15 @@ class DownloadManager:
|
||||
self._progress['current_model'] = f"{model_name} ({model_hash[:8]})"
|
||||
await self._broadcast_progress(status='running')
|
||||
|
||||
# Create model directory
|
||||
model_dir = os.path.join(output_dir, model_hash)
|
||||
model_dir = ExampleImagePathResolver.get_model_folder(model_hash, library_name)
|
||||
if not model_dir:
|
||||
logger.warning(
|
||||
"Unable to resolve example images folder for model %s (%s)",
|
||||
model_name,
|
||||
model_hash,
|
||||
)
|
||||
return False
|
||||
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
|
||||
# First check for local example images - local processing doesn't need delay
|
||||
@@ -627,51 +894,62 @@ class DownloadManager:
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
return False # Return False to indicate no remote download happened
|
||||
|
||||
full_model = await MetadataUpdater.get_updated_model(
|
||||
model_hash, scanner
|
||||
)
|
||||
civitai_payload = (full_model or {}).get('civitai') if full_model else None
|
||||
civitai_payload = civitai_payload or {}
|
||||
|
||||
# If no local images, try to download from remote
|
||||
elif model.get('civitai') and model.get('civitai', {}).get('images'):
|
||||
images = model.get('civitai', {}).get('images', [])
|
||||
|
||||
if civitai_payload.get('images'):
|
||||
images = civitai_payload.get('images', [])
|
||||
|
||||
success, is_stale, failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
|
||||
failed_urls: Set[str] = set(failed_images)
|
||||
|
||||
# If metadata is stale, try to refresh it
|
||||
if is_stale and model_hash not in self._progress['refreshed_models']:
|
||||
await MetadataUpdater.refresh_model_metadata(
|
||||
model_hash, model_name, scanner_type, scanner, self._progress
|
||||
)
|
||||
|
||||
|
||||
# Get the updated model data
|
||||
updated_model = await MetadataUpdater.get_updated_model(
|
||||
model_hash, scanner
|
||||
)
|
||||
|
||||
if updated_model and updated_model.get('civitai', {}).get('images'):
|
||||
updated_civitai = (updated_model or {}).get('civitai') if updated_model else None
|
||||
updated_civitai = updated_civitai or {}
|
||||
|
||||
if updated_civitai.get('images'):
|
||||
# Retry download with updated metadata
|
||||
updated_images = updated_model.get('civitai', {}).get('images', [])
|
||||
updated_images = updated_civitai.get('images', [])
|
||||
success, _, additional_failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
|
||||
model_hash, model_name, updated_images, model_dir, optimize, downloader
|
||||
)
|
||||
|
||||
|
||||
# Combine failed images from both attempts
|
||||
failed_images.extend(additional_failed_images)
|
||||
|
||||
failed_urls.update(additional_failed_images)
|
||||
|
||||
self._progress['refreshed_models'].add(model_hash)
|
||||
|
||||
|
||||
# For forced downloads, remove failed images from metadata
|
||||
if failed_images:
|
||||
# Create a copy of images excluding failed ones
|
||||
if failed_urls:
|
||||
await self._remove_failed_images_from_metadata(
|
||||
model_hash, model_name, failed_images, scanner
|
||||
model_hash, model_name, model_dir, failed_urls, scanner
|
||||
)
|
||||
|
||||
|
||||
# Mark as processed
|
||||
if success or failed_images: # Mark as processed if we successfully downloaded some images or removed failed ones
|
||||
if success or failed_urls: # Mark as processed if we successfully downloaded some images or removed failed ones
|
||||
self._progress['processed_models'].add(model_hash)
|
||||
|
||||
|
||||
return True # Return True to indicate a remote download happened
|
||||
else:
|
||||
logger.debug(f"No civitai images available for model {model_name}")
|
||||
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
@@ -681,49 +959,112 @@ class DownloadManager:
|
||||
self._progress['last_error'] = error_msg
|
||||
return False # Return False on exception
|
||||
|
||||
async def _remove_failed_images_from_metadata(self, model_hash, model_name, failed_images, scanner):
|
||||
"""Remove failed images from model metadata"""
|
||||
async def _remove_failed_images_from_metadata(
|
||||
self,
|
||||
model_hash: str,
|
||||
model_name: str,
|
||||
model_dir: str,
|
||||
failed_images: Iterable[str],
|
||||
scanner,
|
||||
) -> None:
|
||||
"""Mark failed images in model metadata so they won't be retried."""
|
||||
|
||||
failed_set: Set[str] = {url for url in failed_images if url}
|
||||
if not failed_set:
|
||||
return
|
||||
|
||||
try:
|
||||
# Get current model data
|
||||
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
|
||||
if not model_data:
|
||||
logger.warning(f"Could not find model data for {model_name} to remove failed images")
|
||||
return
|
||||
|
||||
if not model_data.get('civitai', {}).get('images'):
|
||||
|
||||
civitai_payload = model_data.get('civitai') or {}
|
||||
current_images = civitai_payload.get('images') or []
|
||||
if not current_images:
|
||||
logger.warning(f"No images in metadata for {model_name}")
|
||||
return
|
||||
|
||||
# Get current images
|
||||
current_images = model_data['civitai']['images']
|
||||
|
||||
# Filter out failed images
|
||||
updated_images = [img for img in current_images if img.get('url') not in failed_images]
|
||||
|
||||
# If images were removed, update metadata
|
||||
if len(updated_images) < len(current_images):
|
||||
removed_count = len(current_images) - len(updated_images)
|
||||
logger.info(f"Removing {removed_count} failed images from metadata for {model_name}")
|
||||
|
||||
# Update the images list
|
||||
model_data['civitai']['images'] = updated_images
|
||||
|
||||
# Save metadata to file
|
||||
file_path = model_data.get('file_path')
|
||||
if file_path:
|
||||
# Create a copy of model data without 'folder' field
|
||||
model_copy = model_data.copy()
|
||||
model_copy.pop('folder', None)
|
||||
|
||||
# Write metadata to file
|
||||
await MetadataManager.save_metadata(file_path, model_copy)
|
||||
logger.info(f"Saved updated metadata for {model_name} after removing failed images")
|
||||
|
||||
# Update the scanner cache
|
||||
|
||||
updated = False
|
||||
|
||||
for image in current_images:
|
||||
image_url = image.get('url')
|
||||
optimized_url = (
|
||||
ExampleImagesProcessor.get_civitai_optimized_url(image_url)
|
||||
if image_url and 'civitai.com' in image_url
|
||||
else None
|
||||
)
|
||||
|
||||
if image_url not in failed_set and optimized_url not in failed_set:
|
||||
continue
|
||||
|
||||
if image.get('downloadFailed'):
|
||||
continue
|
||||
|
||||
image['downloadFailed'] = True
|
||||
image.setdefault('downloadError', 'not_found')
|
||||
logger.debug(
|
||||
"Marked example image %s for %s as failed due to missing remote asset",
|
||||
image_url,
|
||||
model_name,
|
||||
)
|
||||
updated = True
|
||||
|
||||
if not updated:
|
||||
return
|
||||
|
||||
file_path = model_data.get('file_path')
|
||||
if file_path:
|
||||
model_copy = model_data.copy()
|
||||
model_copy.pop('folder', None)
|
||||
await MetadataManager.save_metadata(file_path, model_copy)
|
||||
|
||||
try:
|
||||
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing failed images from metadata for {model_name}: {e}", exc_info=True)
|
||||
except AttributeError:
|
||||
logger.debug("Scanner does not expose cache update for %s", model_name)
|
||||
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
"Error removing failed images from metadata for %s: %s", model_name, exc, exc_info=True
|
||||
)
|
||||
|
||||
def _renumber_example_image_files(self, model_dir: str) -> None:
|
||||
if not model_dir or not os.path.isdir(model_dir):
|
||||
return
|
||||
|
||||
pattern = re.compile(r'^image_(\d+)(\.[^.]+)$', re.IGNORECASE)
|
||||
matches: List[Tuple[int, str, str]] = []
|
||||
|
||||
for entry in os.listdir(model_dir):
|
||||
match = pattern.match(entry)
|
||||
if match:
|
||||
matches.append((int(match.group(1)), entry, match.group(2)))
|
||||
|
||||
if not matches:
|
||||
return
|
||||
|
||||
matches.sort(key=lambda item: item[0])
|
||||
staged_paths: List[Tuple[str, str]] = []
|
||||
|
||||
for _, original_name, extension in matches:
|
||||
source_path = os.path.join(model_dir, original_name)
|
||||
temp_name = f"tmp_{uuid.uuid4().hex}_{original_name}"
|
||||
temp_path = os.path.join(model_dir, temp_name)
|
||||
try:
|
||||
os.rename(source_path, temp_path)
|
||||
staged_paths.append((temp_path, extension))
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to stage rename for %s: %s", source_path, exc)
|
||||
|
||||
for new_index, (temp_path, extension) in enumerate(staged_paths):
|
||||
final_name = f"image_{new_index}{extension}"
|
||||
final_path = os.path.join(model_dir, final_name)
|
||||
try:
|
||||
os.rename(temp_path, final_path)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to finalise rename for %s: %s", final_path, exc)
|
||||
|
||||
async def _broadcast_progress(
|
||||
self,
|
||||
|
||||
@@ -3,7 +3,11 @@ import os
|
||||
import sys
|
||||
import subprocess
|
||||
from aiohttp import web
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..utils.example_images_paths import (
|
||||
get_model_folder,
|
||||
get_model_relative_path,
|
||||
)
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,7 +37,8 @@ class ExampleImagesFileManager:
|
||||
}, status=400)
|
||||
|
||||
# Get example images path from settings
|
||||
example_images_path = settings.get('example_images_path')
|
||||
settings_manager = get_settings_manager()
|
||||
example_images_path = settings_manager.get('example_images_path')
|
||||
if not example_images_path:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
@@ -41,8 +46,12 @@ class ExampleImagesFileManager:
|
||||
}, status=400)
|
||||
|
||||
# Construct folder path for this model
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
model_folder = os.path.abspath(model_folder) # Get absolute path
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Failed to resolve example images folder for this model.'
|
||||
}, status=500)
|
||||
|
||||
# Path validation: ensure model_folder is under example_images_path
|
||||
if not model_folder.startswith(os.path.abspath(example_images_path)):
|
||||
@@ -101,7 +110,8 @@ class ExampleImagesFileManager:
|
||||
}, status=400)
|
||||
|
||||
# Get example images path from settings
|
||||
example_images_path = settings.get('example_images_path')
|
||||
settings_manager = get_settings_manager()
|
||||
example_images_path = settings_manager.get('example_images_path')
|
||||
if not example_images_path:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
@@ -109,8 +119,13 @@ class ExampleImagesFileManager:
|
||||
}, status=400)
|
||||
|
||||
# Construct folder path for this model
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Failed to resolve example images folder for this model'
|
||||
}, status=500)
|
||||
|
||||
# Check if folder exists
|
||||
if not os.path.exists(model_folder):
|
||||
return web.json_response({
|
||||
@@ -128,9 +143,10 @@ class ExampleImagesFileManager:
|
||||
file_ext = os.path.splitext(file)[1].lower()
|
||||
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
|
||||
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
|
||||
relative_path = get_model_relative_path(model_hash)
|
||||
files.append({
|
||||
'name': file,
|
||||
'path': f'/example_images_static/{model_hash}/{file}',
|
||||
'path': f'/example_images_static/{relative_path}/{file}',
|
||||
'extension': file_ext,
|
||||
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
})
|
||||
@@ -169,15 +185,21 @@ class ExampleImagesFileManager:
|
||||
}, status=400)
|
||||
|
||||
# Get example images path from settings
|
||||
example_images_path = settings.get('example_images_path')
|
||||
settings_manager = get_settings_manager()
|
||||
example_images_path = settings_manager.get('example_images_path')
|
||||
if not example_images_path:
|
||||
return web.json_response({
|
||||
'has_images': False
|
||||
})
|
||||
|
||||
# Construct folder path for this model
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
return web.json_response({
|
||||
'has_images': False,
|
||||
'error': 'Failed to resolve example images folder for this model'
|
||||
})
|
||||
|
||||
# Check if folder exists
|
||||
if not os.path.exists(model_folder) or not os.path.isdir(model_folder):
|
||||
return web.json_response({
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
|
||||
from ..recipes.constants import GEN_PARAM_KEYS
|
||||
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
from ..services.metadata_sync_service import MetadataSyncService
|
||||
from ..services.preview_asset_service import PreviewAssetService
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.downloader import get_downloader
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -20,13 +21,46 @@ _preview_service = PreviewAssetService(
|
||||
exif_utils=ExifUtils,
|
||||
)
|
||||
|
||||
_metadata_sync_service = MetadataSyncService(
|
||||
metadata_manager=MetadataManager,
|
||||
preview_service=_preview_service,
|
||||
settings=settings,
|
||||
default_metadata_provider_factory=get_default_metadata_provider,
|
||||
metadata_provider_selector=get_metadata_provider,
|
||||
)
|
||||
_metadata_sync_service: MetadataSyncService | None = None
|
||||
_metadata_sync_service_settings: Optional["SettingsManager"] = None
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..services.settings_manager import SettingsManager
|
||||
|
||||
|
||||
def _build_metadata_sync_service(settings_manager: "SettingsManager") -> MetadataSyncService:
|
||||
"""Construct a metadata sync service bound to the provided settings."""
|
||||
|
||||
return MetadataSyncService(
|
||||
metadata_manager=MetadataManager,
|
||||
preview_service=_preview_service,
|
||||
settings=settings_manager,
|
||||
default_metadata_provider_factory=get_default_metadata_provider,
|
||||
metadata_provider_selector=get_metadata_provider,
|
||||
)
|
||||
|
||||
|
||||
def _get_metadata_sync_service() -> MetadataSyncService:
|
||||
"""Return the shared metadata sync service, initialising it lazily."""
|
||||
|
||||
global _metadata_sync_service, _metadata_sync_service_settings
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
|
||||
if isinstance(_metadata_sync_service, MetadataSyncService):
|
||||
if _metadata_sync_service_settings is not settings_manager:
|
||||
_metadata_sync_service = _build_metadata_sync_service(settings_manager)
|
||||
_metadata_sync_service_settings = settings_manager
|
||||
elif _metadata_sync_service is None:
|
||||
_metadata_sync_service = _build_metadata_sync_service(settings_manager)
|
||||
_metadata_sync_service_settings = settings_manager
|
||||
else:
|
||||
# Tests may inject stand-ins that do not match the sync service type. Preserve
|
||||
# those injections while still updating our cached settings reference so the
|
||||
# next real service instantiation uses the current configuration.
|
||||
_metadata_sync_service_settings = settings_manager
|
||||
|
||||
return _metadata_sync_service
|
||||
|
||||
|
||||
class MetadataUpdater:
|
||||
@@ -71,7 +105,8 @@ class MetadataUpdater:
|
||||
async def update_cache_func(old_path, new_path, metadata):
|
||||
return await scanner.update_single_model_cache(old_path, new_path, metadata)
|
||||
|
||||
success, error = await _metadata_sync_service.fetch_and_update_model(
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
success, error = await _get_metadata_sync_service().fetch_and_update_model(
|
||||
sha256=model_hash,
|
||||
file_path=file_path,
|
||||
model_data=model_data,
|
||||
@@ -95,21 +130,35 @@ class MetadataUpdater:
|
||||
|
||||
@staticmethod
|
||||
async def get_updated_model(model_hash, scanner):
|
||||
"""Get updated model data
|
||||
|
||||
Args:
|
||||
model_hash: SHA256 hash of the model
|
||||
scanner: Scanner instance
|
||||
|
||||
Returns:
|
||||
dict: Updated model data or None if not found
|
||||
"""
|
||||
"""Load the most recent metadata for a model identified by hash."""
|
||||
cache = await scanner.get_cached_data()
|
||||
target = None
|
||||
for item in cache.raw_data:
|
||||
if item.get('sha256') == model_hash:
|
||||
return item
|
||||
return None
|
||||
|
||||
target = item
|
||||
break
|
||||
|
||||
if not target:
|
||||
return None
|
||||
|
||||
file_path = target.get('file_path')
|
||||
if not file_path:
|
||||
return target
|
||||
|
||||
model_cls = getattr(scanner, 'model_class', None)
|
||||
if model_cls is None:
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path)
|
||||
else:
|
||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, model_cls)
|
||||
|
||||
if should_skip or metadata is None:
|
||||
return target
|
||||
|
||||
rich_metadata = metadata.to_dict()
|
||||
rich_metadata.setdefault('folder', target.get('folder', ''))
|
||||
return rich_metadata
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def update_metadata_from_local_examples(model_hash, model, scanner_type, scanner, model_dir):
|
||||
"""Update model metadata with local example image information
|
||||
@@ -137,16 +186,16 @@ class MetadataUpdater:
|
||||
if is_supported:
|
||||
local_images_paths.append(file_path)
|
||||
|
||||
await MetadataManager.hydrate_model_data(model)
|
||||
civitai_data = model.setdefault('civitai', {})
|
||||
|
||||
# Check if metadata update is needed (no civitai field or empty images)
|
||||
needs_update = not model.get('civitai') or not model.get('civitai', {}).get('images')
|
||||
needs_update = not civitai_data or not civitai_data.get('images')
|
||||
|
||||
if needs_update and local_images_paths:
|
||||
logger.debug(f"Found {len(local_images_paths)} local example images for {model.get('model_name')}, updating metadata")
|
||||
|
||||
# Create or get civitai field
|
||||
if not model.get('civitai'):
|
||||
model['civitai'] = {}
|
||||
|
||||
# Create images array
|
||||
images = []
|
||||
|
||||
@@ -181,16 +230,13 @@ class MetadataUpdater:
|
||||
images.append(image_entry)
|
||||
|
||||
# Update the model's civitai.images field
|
||||
model['civitai']['images'] = images
|
||||
civitai_data['images'] = images
|
||||
|
||||
# Save metadata to .metadata.json file
|
||||
file_path = model.get('file_path')
|
||||
try:
|
||||
# Create a copy of model data without 'folder' field
|
||||
model_copy = model.copy()
|
||||
model_copy.pop('folder', None)
|
||||
|
||||
# Write metadata to file
|
||||
await MetadataManager.save_metadata(file_path, model_copy)
|
||||
logger.info(f"Saved metadata for {model.get('model_name')}")
|
||||
except Exception as e:
|
||||
@@ -223,16 +269,18 @@ class MetadataUpdater:
|
||||
tuple: (regular_images, custom_images) - Both image arrays
|
||||
"""
|
||||
try:
|
||||
# Ensure civitai field exists in model_data
|
||||
if not model_data.get('civitai'):
|
||||
model_data['civitai'] = {}
|
||||
|
||||
# Ensure customImages array exists
|
||||
if not model_data['civitai'].get('customImages'):
|
||||
model_data['civitai']['customImages'] = []
|
||||
|
||||
# Get current customImages array
|
||||
custom_images = model_data['civitai']['customImages']
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
civitai_data = model_data.get('civitai')
|
||||
|
||||
if not isinstance(civitai_data, dict):
|
||||
civitai_data = {}
|
||||
model_data['civitai'] = civitai_data
|
||||
|
||||
custom_images = civitai_data.get('customImages')
|
||||
|
||||
if not isinstance(custom_images, list):
|
||||
custom_images = []
|
||||
civitai_data['customImages'] = custom_images
|
||||
|
||||
# Add new image entry for each imported file
|
||||
for path_tuple in newly_imported_paths:
|
||||
@@ -290,11 +338,8 @@ class MetadataUpdater:
|
||||
file_path = model_data.get('file_path')
|
||||
if file_path:
|
||||
try:
|
||||
# Create a copy of model data without 'folder' field
|
||||
model_copy = model_data.copy()
|
||||
model_copy.pop('folder', None)
|
||||
|
||||
# Write metadata to file
|
||||
await MetadataManager.save_metadata(file_path, model_copy)
|
||||
logger.info(f"Saved metadata for {model_data.get('model_name')}")
|
||||
except Exception as e:
|
||||
@@ -305,7 +350,7 @@ class MetadataUpdater:
|
||||
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
||||
|
||||
# Get regular images array (might be None)
|
||||
regular_images = model_data['civitai'].get('images', [])
|
||||
regular_images = civitai_data.get('images', [])
|
||||
|
||||
# Return both image arrays
|
||||
return regular_images, custom_images
|
||||
@@ -406,4 +451,4 @@ class MetadataUpdater:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -3,8 +3,9 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.example_images_paths import iter_library_roots
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
@@ -13,35 +14,60 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
|
||||
|
||||
|
||||
class _SettingsProxy:
|
||||
def __init__(self):
|
||||
self._manager = None
|
||||
|
||||
def _resolve(self):
|
||||
if self._manager is None:
|
||||
self._manager = get_settings_manager()
|
||||
return self._manager
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return self._resolve().get(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._resolve(), item)
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
|
||||
class ExampleImagesMigration:
|
||||
"""Handles migrations for example images naming conventions"""
|
||||
|
||||
@staticmethod
|
||||
async def check_and_run_migrations():
|
||||
"""Check if migrations are needed and run them in background"""
|
||||
example_images_path = settings.get('example_images_path')
|
||||
if not example_images_path or not os.path.exists(example_images_path):
|
||||
root = settings.get('example_images_path')
|
||||
if not root or not os.path.exists(root):
|
||||
logger.debug("No example images path configured or path doesn't exist, skipping migrations")
|
||||
return
|
||||
|
||||
# Check current version from progress file
|
||||
current_version = 0
|
||||
progress_file = os.path.join(example_images_path, '.download_progress.json')
|
||||
if os.path.exists(progress_file):
|
||||
try:
|
||||
with open(progress_file, 'r', encoding='utf-8') as f:
|
||||
progress_data = json.load(f)
|
||||
current_version = progress_data.get('naming_version', 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load progress file for migration check: {e}")
|
||||
|
||||
# If current version is less than target version, start migration
|
||||
if current_version < CURRENT_NAMING_VERSION:
|
||||
logger.info(f"Starting example images naming migration from v{current_version} to v{CURRENT_NAMING_VERSION}")
|
||||
# Start migration in background task
|
||||
asyncio.create_task(
|
||||
ExampleImagesMigration.run_migrations(example_images_path, current_version, CURRENT_NAMING_VERSION)
|
||||
)
|
||||
|
||||
for library_name, library_path in iter_library_roots():
|
||||
if not library_path or not os.path.exists(library_path):
|
||||
continue
|
||||
|
||||
current_version = 0
|
||||
progress_file = os.path.join(library_path, '.download_progress.json')
|
||||
if os.path.exists(progress_file):
|
||||
try:
|
||||
with open(progress_file, 'r', encoding='utf-8') as f:
|
||||
progress_data = json.load(f)
|
||||
current_version = progress_data.get('naming_version', 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load progress file for migration check: {e}")
|
||||
|
||||
if current_version < CURRENT_NAMING_VERSION:
|
||||
logger.info(
|
||||
"Starting example images naming migration from v%s to v%s for library '%s'",
|
||||
current_version,
|
||||
CURRENT_NAMING_VERSION,
|
||||
library_name,
|
||||
)
|
||||
asyncio.create_task(
|
||||
ExampleImagesMigration.run_migrations(library_path, current_version, CURRENT_NAMING_VERSION)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def run_migrations(example_images_path, from_version, to_version):
|
||||
|
||||
229
py/utils/example_images_paths.py
Normal file
229
py/utils/example_images_paths.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Utility helpers for resolving example image storage paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from typing import Iterable, List, Optional, Tuple
|
||||
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
|
||||
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_configured_libraries() -> List[str]:
|
||||
"""Return configured library names if multi-library support is enabled."""
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
libraries = settings_manager.get("libraries")
|
||||
if isinstance(libraries, dict) and libraries:
|
||||
return list(libraries.keys())
|
||||
return []
|
||||
|
||||
|
||||
def get_example_images_root() -> str:
|
||||
"""Return the root directory configured for example images."""
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
root = settings_manager.get("example_images_path") or ""
|
||||
return os.path.abspath(root) if root else ""
|
||||
|
||||
|
||||
def uses_library_scoped_folders() -> bool:
|
||||
"""Return True when example images should be separated per library."""
|
||||
|
||||
libraries = _get_configured_libraries()
|
||||
return len(libraries) > 1
|
||||
|
||||
|
||||
def sanitize_library_name(library_name: Optional[str]) -> str:
|
||||
"""Return a filesystem safe library name."""
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
name = library_name or settings_manager.get_active_library_name() or "default"
|
||||
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
|
||||
return safe_name or "default"
|
||||
|
||||
|
||||
def get_library_root(library_name: Optional[str] = None) -> str:
|
||||
"""Return the directory where a library's example images should live."""
|
||||
|
||||
root = get_example_images_root()
|
||||
if not root:
|
||||
return ""
|
||||
|
||||
if uses_library_scoped_folders():
|
||||
return os.path.join(root, sanitize_library_name(library_name))
|
||||
return root
|
||||
|
||||
|
||||
def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
|
||||
"""Ensure the example image directory for a library exists and return it."""
|
||||
|
||||
library_root = get_library_root(library_name)
|
||||
if library_root:
|
||||
os.makedirs(library_root, exist_ok=True)
|
||||
return library_root
|
||||
|
||||
|
||||
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||
"""Return the folder path for a model's example images."""
|
||||
|
||||
if not model_hash:
|
||||
return ""
|
||||
|
||||
library_root = ensure_library_root_exists(library_name)
|
||||
if not library_root:
|
||||
return ""
|
||||
|
||||
normalized_hash = (model_hash or "").lower()
|
||||
resolved_folder = os.path.join(library_root, normalized_hash)
|
||||
|
||||
if uses_library_scoped_folders():
|
||||
legacy_root = get_example_images_root()
|
||||
legacy_folder = os.path.join(legacy_root, normalized_hash)
|
||||
if os.path.exists(legacy_folder) and not os.path.exists(resolved_folder):
|
||||
try:
|
||||
os.makedirs(library_root, exist_ok=True)
|
||||
shutil.move(legacy_folder, resolved_folder)
|
||||
logger.info(
|
||||
"Migrated legacy example images folder '%s' to '%s'", legacy_folder, resolved_folder
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"Failed to migrate example images from '%s' to '%s': %s",
|
||||
legacy_folder,
|
||||
resolved_folder,
|
||||
exc,
|
||||
)
|
||||
return legacy_folder
|
||||
|
||||
return resolved_folder
|
||||
|
||||
|
||||
class ExampleImagePathResolver:
|
||||
"""Convenience wrapper exposing example image path helpers."""
|
||||
|
||||
@staticmethod
|
||||
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||
"""Return the example image folder for a model, migrating legacy paths."""
|
||||
|
||||
return get_model_folder(model_hash, library_name)
|
||||
|
||||
@staticmethod
|
||||
def get_library_root(library_name: Optional[str] = None) -> str:
|
||||
"""Return the configured library root for example images."""
|
||||
|
||||
return get_library_root(library_name)
|
||||
|
||||
@staticmethod
|
||||
def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
|
||||
"""Ensure the library root exists before writing files."""
|
||||
|
||||
return ensure_library_root_exists(library_name)
|
||||
|
||||
@staticmethod
|
||||
def get_model_relative_path(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||
"""Return the relative path to a model folder from the static mount point."""
|
||||
|
||||
return get_model_relative_path(model_hash, library_name)
|
||||
|
||||
|
||||
def get_model_relative_path(model_hash: str, library_name: Optional[str] = None) -> str:
|
||||
"""Return the relative URL path from the static mount to a model folder."""
|
||||
|
||||
root = get_example_images_root()
|
||||
folder = get_model_folder(model_hash, library_name)
|
||||
if not root or not folder:
|
||||
return ""
|
||||
|
||||
try:
|
||||
relative = os.path.relpath(folder, root)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
return relative.replace("\\", "/")
|
||||
|
||||
|
||||
def iter_library_roots() -> Iterable[Tuple[str, str]]:
|
||||
"""Yield configured library names and their resolved filesystem roots."""
|
||||
|
||||
root = get_example_images_root()
|
||||
if not root:
|
||||
return []
|
||||
|
||||
libraries = _get_configured_libraries()
|
||||
if uses_library_scoped_folders():
|
||||
results: List[Tuple[str, str]] = []
|
||||
if libraries:
|
||||
for library in libraries:
|
||||
results.append((library, get_library_root(library)))
|
||||
else:
|
||||
# Fall back to the active library to avoid skipping migrations/cleanup
|
||||
settings_manager = get_settings_manager()
|
||||
active = settings_manager.get_active_library_name() or "default"
|
||||
results.append((active, get_library_root(active)))
|
||||
return results
|
||||
|
||||
settings_manager = get_settings_manager()
|
||||
active = settings_manager.get_active_library_name() or "default"
|
||||
return [(active, root)]
|
||||
|
||||
|
||||
def is_hash_folder(name: str) -> bool:
|
||||
"""Return True if the provided name looks like a model hash folder."""
|
||||
|
||||
return bool(_HEX_PATTERN.fullmatch(name or ""))
|
||||
|
||||
|
||||
def is_valid_example_images_root(folder_path: str) -> bool:
|
||||
"""Check whether a folder looks like a dedicated example images root."""
|
||||
|
||||
try:
|
||||
items = os.listdir(folder_path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
for item in items:
|
||||
item_path = os.path.join(folder_path, item)
|
||||
if item == ".download_progress.json" and os.path.isfile(item_path):
|
||||
continue
|
||||
|
||||
if os.path.isdir(item_path):
|
||||
if is_hash_folder(item):
|
||||
continue
|
||||
if item == "_deleted":
|
||||
# Allow cleanup staging folders
|
||||
continue
|
||||
# Accept legacy library folders even when current settings do not
|
||||
# explicitly enable multi-library mode. This allows users to reuse a
|
||||
# previously configured example images directory after settings are
|
||||
# reset, as long as the nested structure still looks like dedicated
|
||||
# hash folders.
|
||||
if _library_folder_has_only_hash_dirs(item_path):
|
||||
continue
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _library_folder_has_only_hash_dirs(path: str) -> bool:
|
||||
"""Return True when a library subfolder only contains hash folders or metadata files."""
|
||||
|
||||
try:
|
||||
for entry in os.listdir(path):
|
||||
entry_path = os.path.join(path, entry)
|
||||
if entry == ".download_progress.json" and os.path.isfile(entry_path):
|
||||
continue
|
||||
if entry == "_deleted" and os.path.isdir(entry_path):
|
||||
continue
|
||||
if not os.path.isdir(entry_path) or not is_hash_folder(entry):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -6,7 +6,8 @@ import string
|
||||
from aiohttp import web
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import settings
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..utils.example_images_paths import get_model_folder, get_model_relative_path
|
||||
from .example_images_metadata import MetadataUpdater
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
|
||||
@@ -84,6 +85,16 @@ class ExampleImagesProcessor:
|
||||
# Default fallback
|
||||
return '.jpg'
|
||||
|
||||
@staticmethod
|
||||
def _is_not_found_error(error) -> bool:
|
||||
"""Return True when the downloader response represents a 404/Not Found."""
|
||||
|
||||
if not error:
|
||||
return False
|
||||
|
||||
message = str(error).lower()
|
||||
return '404' in message or 'file not found' in message
|
||||
|
||||
@staticmethod
|
||||
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
|
||||
"""Download images for a single model
|
||||
@@ -97,7 +108,15 @@ class ExampleImagesProcessor:
|
||||
image_url = image.get('url')
|
||||
if not image_url:
|
||||
continue
|
||||
|
||||
|
||||
if image.get('downloadFailed'):
|
||||
logger.debug(
|
||||
"Skipping example image %s for %s because it previously failed to download",
|
||||
image_url,
|
||||
model_name,
|
||||
)
|
||||
continue
|
||||
|
||||
# Apply optimization for Civitai URLs if enabled
|
||||
original_url = image_url
|
||||
if optimize and 'civitai.com' in image_url:
|
||||
@@ -141,7 +160,7 @@ class ExampleImagesProcessor:
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
elif "404" in str(content):
|
||||
elif ExampleImagesProcessor._is_not_found_error(content):
|
||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||
logger.warning(error_msg)
|
||||
model_success = False # Mark the model as failed due to 404 error
|
||||
@@ -172,7 +191,15 @@ class ExampleImagesProcessor:
|
||||
image_url = image.get('url')
|
||||
if not image_url:
|
||||
continue
|
||||
|
||||
|
||||
if image.get('downloadFailed'):
|
||||
logger.debug(
|
||||
"Skipping example image %s for %s because it previously failed to download",
|
||||
image_url,
|
||||
model_name,
|
||||
)
|
||||
continue
|
||||
|
||||
# Apply optimization for Civitai URLs if enabled
|
||||
original_url = image_url
|
||||
if optimize and 'civitai.com' in image_url:
|
||||
@@ -216,7 +243,7 @@ class ExampleImagesProcessor:
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
elif "404" in str(content):
|
||||
elif ExampleImagesProcessor._is_not_found_error(content):
|
||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
||||
logger.warning(error_msg)
|
||||
model_success = False # Mark the model as failed due to 404 error
|
||||
@@ -317,7 +344,7 @@ class ExampleImagesProcessor:
|
||||
|
||||
try:
|
||||
# Get example images path
|
||||
example_images_path = settings.get('example_images_path')
|
||||
example_images_path = get_settings_manager().get('example_images_path')
|
||||
if not example_images_path:
|
||||
raise ExampleImagesValidationError('No example images path configured')
|
||||
|
||||
@@ -346,7 +373,9 @@ class ExampleImagesProcessor:
|
||||
)
|
||||
|
||||
# Create model folder
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
raise ExampleImagesImportError('Failed to resolve model folder for example images')
|
||||
os.makedirs(model_folder, exist_ok=True)
|
||||
|
||||
imported_files = []
|
||||
@@ -383,7 +412,7 @@ class ExampleImagesProcessor:
|
||||
# Add to imported files list
|
||||
imported_files.append({
|
||||
'name': new_filename,
|
||||
'path': f'/example_images_static/{model_hash}/{new_filename}',
|
||||
'path': f'/example_images_static/{get_model_relative_path(model_hash)}/{new_filename}',
|
||||
'extension': file_ext,
|
||||
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
||||
})
|
||||
@@ -439,7 +468,7 @@ class ExampleImagesProcessor:
|
||||
}, status=400)
|
||||
|
||||
# Get example images path
|
||||
example_images_path = settings.get('example_images_path')
|
||||
example_images_path = get_settings_manager().get('example_images_path')
|
||||
if not example_images_path:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
@@ -472,15 +501,17 @@ class ExampleImagesProcessor:
|
||||
'error': f"Model with hash {model_hash} not found in cache"
|
||||
}, status=404)
|
||||
|
||||
# Check if model has custom images
|
||||
if not model_data.get('civitai', {}).get('customImages'):
|
||||
await MetadataManager.hydrate_model_data(model_data)
|
||||
civitai_data = model_data.setdefault('civitai', {})
|
||||
custom_images = civitai_data.get('customImages')
|
||||
|
||||
if not isinstance(custom_images, list) or not custom_images:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Model has no custom images"
|
||||
}, status=404)
|
||||
|
||||
# Find the custom image with matching short_id
|
||||
custom_images = model_data['civitai']['customImages']
|
||||
matching_image = None
|
||||
new_custom_images = []
|
||||
|
||||
@@ -497,7 +528,12 @@ class ExampleImagesProcessor:
|
||||
}, status=404)
|
||||
|
||||
# Find and delete the actual file
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
model_folder = get_model_folder(model_hash)
|
||||
if not model_folder:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Failed to resolve model folder for example images'
|
||||
}, status=500)
|
||||
file_deleted = False
|
||||
|
||||
if os.path.exists(model_folder):
|
||||
@@ -519,17 +555,15 @@ class ExampleImagesProcessor:
|
||||
logger.warning(f"File for custom example with id {short_id} not found, but metadata will still be updated")
|
||||
|
||||
# Update metadata
|
||||
model_data['civitai']['customImages'] = new_custom_images
|
||||
civitai_data['customImages'] = new_custom_images
|
||||
model_data.setdefault('civitai', {})['customImages'] = new_custom_images
|
||||
|
||||
# Save updated metadata to file
|
||||
file_path = model_data.get('file_path')
|
||||
if file_path:
|
||||
try:
|
||||
# Create a copy of model data without 'folder' field
|
||||
model_copy = model_data.copy()
|
||||
model_copy.pop('folder', None)
|
||||
|
||||
# Write metadata to file
|
||||
await MetadataManager.save_metadata(file_path, model_copy)
|
||||
logger.debug(f"Saved updated metadata for {model_data.get('model_name')}")
|
||||
except Exception as e:
|
||||
@@ -543,7 +577,7 @@ class ExampleImagesProcessor:
|
||||
await scanner.update_single_model_cache(file_path, file_path, model_data)
|
||||
|
||||
# Get regular images array (might be None)
|
||||
regular_images = model_data['civitai'].get('images', [])
|
||||
regular_images = civitai_data.get('images', [])
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
@@ -560,4 +594,4 @@ class ExampleImagesProcessor:
|
||||
}, status=500)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from typing import Optional
|
||||
from io import BytesIO
|
||||
import os
|
||||
from PIL import Image
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,9 +86,10 @@ class ExifUtils:
|
||||
|
||||
# For PNG, try to update parameters directly
|
||||
if img_format == 'PNG':
|
||||
# We'll save with parameters in the PNG info
|
||||
info_dict = {'parameters': metadata}
|
||||
img.save(image_path, format='PNG', pnginfo=info_dict)
|
||||
# Use PngInfo instead of plain dictionary
|
||||
png_info = PngImagePlugin.PngInfo()
|
||||
png_info.add_text("parameters", metadata)
|
||||
img.save(image_path, format='PNG', pnginfo=png_info)
|
||||
return image_path
|
||||
|
||||
# For WebP format, use PIL's exif parameter directly
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Optional, Type, Union
|
||||
from typing import Any, Dict, Optional, Type, Union
|
||||
|
||||
from .models import BaseModelMetadata, LoraMetadata
|
||||
from .file_utils import normalize_path, find_preview_file, calculate_sha256
|
||||
@@ -53,6 +53,70 @@ class MetadataManager:
|
||||
error_type = "Invalid JSON" if isinstance(e, json.JSONDecodeError) else "Parse error"
|
||||
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
|
||||
return None, True # should_skip = True
|
||||
|
||||
@staticmethod
|
||||
async def load_metadata_payload(file_path: str) -> Dict:
|
||||
"""
|
||||
Load metadata and return it as a dictionary, including any unknown fields.
|
||||
Falls back to reading the raw JSON file if parsing into a model class fails.
|
||||
"""
|
||||
|
||||
payload: Dict = {}
|
||||
metadata_obj, should_skip = await MetadataManager.load_metadata(file_path)
|
||||
|
||||
if metadata_obj:
|
||||
payload = metadata_obj.to_dict()
|
||||
unknown_fields = getattr(metadata_obj, "_unknown_fields", None)
|
||||
if isinstance(unknown_fields, dict):
|
||||
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"
|
||||
)
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
raw = json.load(handle)
|
||||
if isinstance(raw, dict):
|
||||
payload = raw
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Failed to parse metadata file %s while loading payload",
|
||||
metadata_path,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to read metadata file %s: %s", metadata_path, exc)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
if file_path:
|
||||
payload.setdefault("file_path", normalize_path(file_path))
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
async def hydrate_model_data(model_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Replace the provided model data with the authoritative payload from disk.
|
||||
Preserves the cached folder entry if present.
|
||||
"""
|
||||
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path:
|
||||
return model_data
|
||||
|
||||
folder = model_data.get("folder")
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
if folder is not None:
|
||||
payload["folder"] = folder
|
||||
|
||||
model_data.clear()
|
||||
model_data.update(payload)
|
||||
return model_data
|
||||
|
||||
@staticmethod
|
||||
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict]) -> bool:
|
||||
|
||||
@@ -18,18 +18,22 @@ class BaseModelMetadata:
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether from Civitai
|
||||
civitai: Optional[Dict] = None # Civitai API data if available
|
||||
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
modelDescription: str = "" # Full model description
|
||||
civitai_deleted: bool = False # Whether deleted from Civitai
|
||||
favorite: bool = False # Whether the model is a favorite
|
||||
exclude: bool = False # Whether to exclude this model from the cache
|
||||
db_checked: bool = False # Whether checked in archive DB
|
||||
metadata_source: Optional[str] = None # Last provider that supplied metadata
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
|
||||
|
||||
def __post_init__(self):
|
||||
# Initialize empty lists to avoid mutable default parameter issue
|
||||
if self.civitai is None:
|
||||
self.civitai = {}
|
||||
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
|
||||
63
py/utils/preview_selection.py
Normal file
63
py/utils/preview_selection.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Utilities for selecting preview media from Civitai image metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from .constants import NSFW_LEVELS
|
||||
|
||||
PreviewMedia = Mapping[str, object]
|
||||
|
||||
|
||||
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
"""Return a normalized NSFW level value for the supplied media entry."""
|
||||
|
||||
value = entry.get("nsfwLevel", 0)
|
||||
try:
|
||||
return int(value) # type: ignore[return-value]
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def select_preview_media(
|
||||
images: Sequence[Mapping[str, object]] | None,
|
||||
*,
|
||||
blur_mature_content: bool,
|
||||
) -> Tuple[Optional[PreviewMedia], int]:
|
||||
"""Select the most appropriate preview media entry.
|
||||
|
||||
When ``blur_mature_content`` is enabled we first try to return the first media
|
||||
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
|
||||
available we return the media entry with the lowest NSFW level. When the
|
||||
setting is disabled we simply return the first entry.
|
||||
"""
|
||||
|
||||
if not images:
|
||||
return None, 0
|
||||
|
||||
candidates = [item for item in images if isinstance(item, Mapping)]
|
||||
if not candidates:
|
||||
return None, 0
|
||||
|
||||
selected = candidates[0]
|
||||
selected_level = _extract_nsfw_level(selected)
|
||||
|
||||
if not blur_mature_content:
|
||||
return selected, selected_level
|
||||
|
||||
safe_threshold = NSFW_LEVELS.get("R", 4)
|
||||
for candidate in candidates:
|
||||
level = _extract_nsfw_level(candidate)
|
||||
if level < safe_threshold:
|
||||
return candidate, level
|
||||
|
||||
for candidate in candidates[1:]:
|
||||
level = _extract_nsfw_level(candidate)
|
||||
if level < selected_level:
|
||||
selected = candidate
|
||||
selected_level = level
|
||||
|
||||
return selected, selected_level
|
||||
|
||||
|
||||
__all__ = ["select_preview_media"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user