mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 11:34:08 -03:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c2b2aedcc | ||
|
|
ebc31fb963 | ||
|
|
9659df6ad9 | ||
|
|
04d131e9dc | ||
|
|
78fe6282c7 | ||
|
|
0c00ee22fc | ||
|
|
5fd4946b1f | ||
|
|
f1d3ac0cdc | ||
|
|
e2c45905f0 | ||
|
|
b2c68e6a65 | ||
|
|
eb0f6dd3b6 | ||
|
|
0bf87f9092 | ||
|
|
1da2433bb2 | ||
|
|
2d6cf545b9 | ||
|
|
6a259a14fa | ||
|
|
41e1fd1e1f | ||
|
|
95fb3c7fc9 | ||
|
|
8237e5f9ea | ||
|
|
aa75986178 | ||
|
|
b887922055 | ||
|
|
68fa0f29c7 | ||
|
|
d9d362c9c9 | ||
|
|
d0bc4be0dc | ||
|
|
420530f532 | ||
|
|
3001f0f0ef | ||
|
|
b2a1307d23 | ||
|
|
64da845a58 | ||
|
|
27027c4497 | ||
|
|
86c85c08ec | ||
|
|
196c8ffc3e | ||
|
|
cfc95ee02a | ||
|
|
479fa36997 | ||
|
|
3e1216e9bc | ||
|
|
007883b7d1 | ||
|
|
dc9200a12c | ||
|
|
d2f955266d | ||
|
|
8e724538bd | ||
|
|
6fcdeb799d | ||
|
|
97b9b1f62b | ||
|
|
4bf9a4b640 | ||
|
|
c5088772e8 | ||
|
|
56acefbd6c | ||
|
|
5ab06c4aae | ||
|
|
c11f4b5c68 | ||
|
|
86376284f4 | ||
|
|
2b8a2fc7d8 | ||
|
|
f26e1b41c8 | ||
|
|
c1671af99f | ||
|
|
ac7707d0f6 | ||
|
|
381cd710a2 | ||
|
|
ad0d18cb79 | ||
|
|
7980ee77d0 | ||
|
|
916b8bb327 | ||
|
|
87e3d4dea9 | ||
|
|
76a913f5e0 | ||
|
|
d8c192e647 | ||
|
|
c453437620 | ||
|
|
720fa6d909 | ||
|
|
b4f71089f4 | ||
|
|
83e6657ead | ||
|
|
7ea6df4111 | ||
|
|
d9ab92602a | ||
|
|
5ffadaed31 |
@@ -1,47 +1,145 @@
|
|||||||
---
|
---
|
||||||
name: lora-manager-e2e
|
name: lora-manager-e2e
|
||||||
description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode, including starting/restarting the server, using Chrome DevTools MCP to interact with the web UI at http://127.0.0.1:8188/loras, and verifying frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend.
|
description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode in a SANDBOXED, disposable configuration: check the port, start/restart the standalone server on a free port, use Chrome DevTools MCP to interact with the web UI (http://127.0.0.1:{PORT}/loras), and verify frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox.
|
||||||
---
|
---
|
||||||
|
|
||||||
# LoRa Manager E2E Testing
|
# LoRa Manager E2E Testing
|
||||||
|
|
||||||
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
|
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
|
||||||
|
|
||||||
|
## Conventions Used in This Document
|
||||||
|
|
||||||
|
- **`{PORT}`**: The server port. The default candidate is `8188`, but **`8188` is commonly occupied by a live ComfyUI process** and MUST NOT be assumed to be free. Always check availability first (see [Port Selection](#port-selection)) and use a free port (e.g. `8199`) for the E2E run. Substitute the actual port for every `{PORT}` in the commands below.
|
||||||
|
- **`<repo-root>`**: The repository/worktree root. Always run commands from the repo or worktree root; never assume a specific absolute path (paths such as `/home/<user>/...` differ per machine). The E2E scripts resolve the project root themselves, but fixture/settings paths are relative to `<repo-root>`.
|
||||||
|
|
||||||
|
## SANDBOX (MANDATORY)
|
||||||
|
|
||||||
|
> **Read this section before running anything.** Every E2E run MUST target a throwaway sandbox, never the real user data. A fresh subagent that skips this section WILL permanently mutate real user recipes.
|
||||||
|
|
||||||
|
1. **Portable settings**: create `<repo-root>/settings.json` (gitignored) with `"use_portable_settings": true` plus sandboxed `folder_paths` (lora/checkpoint roots) and `recipes_path`. This keeps the configuration inside the repo instead of the real user config dir (`~/.config/ComfyUI-LoRA-Manager/settings.json`).
|
||||||
|
2. **Sandboxed paths**: point `folder_paths` / `recipes_path` / `example_images_path` at disposable dirs — e.g. under `/tmp/opencode/<plan-name>-e2e/` (or worktree-local dirs). NEVER point the E2E at the real library (`~/models/...`), real recipe dir, or real settings.
|
||||||
|
3. **Never touch the real config**: the real user config at `~/.config/ComfyUI-LoRA-Manager/settings.json` and the real recipe dir must remain byte-identical before and after the run.
|
||||||
|
4. **Record real-data protection proof** before starting and after finishing:
|
||||||
|
```bash
|
||||||
|
# BEFORE: snapshot real config + recipe library state
|
||||||
|
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > /tmp/opencode/<plan>-e2e/settings.before.sha256
|
||||||
|
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > /tmp/opencode/<plan>-e2e/recipes-count.before.txt
|
||||||
|
find ~/models/recipes -name '*.recipe.json' -newermt "$(date -Iseconds)" | head # expect empty after run
|
||||||
|
# AFTER: record again, then diff the two snapshots. Any change = the run leaked into real data.
|
||||||
|
```
|
||||||
|
Also confirm `<repo-root>/git status` stays clean for `settings.json`/`cache/` (both are gitignored).
|
||||||
|
|
||||||
|
### Portable Settings Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"use_portable_settings": true,
|
||||||
|
"folder_paths": {
|
||||||
|
"loras": ["/tmp/opencode/<plan>-e2e/models/loras"],
|
||||||
|
"checkpoints": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
|
||||||
|
"unet": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
|
||||||
|
"diffusers": []
|
||||||
|
},
|
||||||
|
"recipes_path": "/tmp/opencode/<plan>-e2e/recipes",
|
||||||
|
"example_images_path": "/tmp/opencode/<plan>-e2e/example_images"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The scanner computes and persists model hashes during the library scan, so the sandbox model dirs just need the model files + `.metadata.json` sidecars (see [Fixture + Fresh-State Guidance](#fixture--fresh-state-guidance)).
|
||||||
|
|
||||||
|
## Time Budgets & Abort Guidance
|
||||||
|
|
||||||
|
A fresh subagent should complete a sandboxed standalone E2E **in well under 30 minutes**. Budget each phase:
|
||||||
|
|
||||||
|
| Phase | Expected duration | Abort if |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Port check + sandbox setup | < 2 min | — |
|
||||||
|
| Server start (detached) + readiness | < 30 s | > 60 s (2x) → stop |
|
||||||
|
| Chrome DevTools MCP connect | < 1 min | > 2 min → stop |
|
||||||
|
| Per entry-point run (after fixtures ready) | < 5 min | > 10 min (2x) → stop |
|
||||||
|
| Fixture reset + cache clear between runs | < 1 min | > 2 min → stop |
|
||||||
|
|
||||||
|
**Abort rule**: if a phase exceeds ~2x its budget, OR any single tool call fails/retries 3+ times in a row, **STOP**. Do not loop or retry blindly. Report `BLOCKED` with: the phase, the last observed state (server PID + `ss -tlnp` output, page snapshot, last API response), and the suspected cause. Record the partial state as evidence; a clean BLOCKED report is more valuable than an hour of retries.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`)
|
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`) — run everything from `<repo-root>`
|
||||||
- Chrome browser available for debugging
|
- Chrome browser available for debugging
|
||||||
- Chrome DevTools MCP connected
|
- Chrome DevTools MCP connected
|
||||||
|
- `ss` (or `lsof`/`netstat`) available for port checks: `ss -tlnp`
|
||||||
|
|
||||||
## Quick Start Workflow
|
## Port Selection
|
||||||
|
|
||||||
### 1. Start LoRa Manager Standalone
|
`8188` is only the *default candidate*. Verify it is actually free before every run:
|
||||||
|
|
||||||
```python
|
|
||||||
# Use the provided script to start the server
|
|
||||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188
|
|
||||||
```
|
|
||||||
|
|
||||||
Or manually:
|
|
||||||
```bash
|
|
||||||
cd /home/miao/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager
|
|
||||||
python standalone.py --port 8188
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait for server ready message before proceeding.
|
|
||||||
|
|
||||||
### 2. Open Chrome Debug Mode
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Chrome with remote debugging on port 9222
|
# Is anything listening on 8188?
|
||||||
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras
|
ss -tlnp | grep ':8188' || echo "8188 is free"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Connect Chrome DevTools MCP
|
- If a process holds `8188` (e.g. a live ComfyUI — pid 6575 on this machine), pick a different free port, e.g. `8199`:
|
||||||
|
```bash
|
||||||
|
ss -tlnp | grep ':8199' || echo "8199 is free"
|
||||||
|
```
|
||||||
|
- **Never** kill a process you did not start for this E2E. The live ComfyUI is off-limits. Pick a free port instead.
|
||||||
|
- Use your chosen port for **all** subsequent commands (server, Chrome launch, browser URLs).
|
||||||
|
|
||||||
Ensure the MCP server is connected to Chrome at `http://localhost:9222`.
|
## Quick Start Workflow (sandboxed)
|
||||||
|
|
||||||
### 4. Navigate and Interact
|
### 1. Prepare the sandbox
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <repo-root> # ALWAYS run from the repo/worktree root
|
||||||
|
mkdir -p /tmp/opencode/<plan>-e2e/models/{loras,checkpoints}
|
||||||
|
mkdir -p /tmp/opencode/<plan>-e2e/{recipes,example_images,recipes-before}
|
||||||
|
# write <repo-root>/settings.json per the portable-settings example above
|
||||||
|
# record real-data protection proof (see SANDBOX section)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Check port availability
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -tlnp | grep ':{PORT}' || echo "port {PORT} is free"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `{PORT}` is occupied by an unrelated process, pick a free one and use it everywhere below. When in doubt use `8199`.
|
||||||
|
|
||||||
|
### 3. Start LoRa Manager Standalone (detached)
|
||||||
|
|
||||||
|
The standalone server **dies with the shell unless launched fully detached** — a plain background `&` from the bash tool is killed when the tool call returns. Launch via the helper script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --wait --timeout 30 --detach
|
||||||
|
```
|
||||||
|
|
||||||
|
Or manually (equivalent detached form):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 < /dev/null \
|
||||||
|
>> /tmp/opencode/<plan>-e2e/server.log 2>&1 &
|
||||||
|
echo "started" # record the printed/pidfile PID for cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify it is listening **before** proceeding (readiness poll is not a substitute for this):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ss -tlnp | grep ':{PORT}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the server PID for cleanup: the helper script writes it to `/tmp/lora-manager-e2e-server-{PORT}.pid`; a manual `setsid` launch has no pidfile, so capture it explicitly (e.g. from `ss -tlnp`).
|
||||||
|
|
||||||
|
### 4. Open Chrome Debug Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Chrome with remote debugging on port 9222 (note the {PORT} URL)
|
||||||
|
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Connect Chrome DevTools MCP
|
||||||
|
|
||||||
|
Ensure the MCP server is connected to Chrome at `http://localhost:9222`. Verify with `list_pages` — if it fails with "browser is already running", see [Chrome DevTools MCP Troubleshooting](#chrome-devtools-mcp-troubleshooting).
|
||||||
|
|
||||||
|
### 6. Navigate and Interact
|
||||||
|
|
||||||
Use Chrome DevTools MCP tools to:
|
Use Chrome DevTools MCP tools to:
|
||||||
- Take snapshots: `take_snapshot`
|
- Take snapshots: `take_snapshot`
|
||||||
@@ -56,7 +154,7 @@ Use Chrome DevTools MCP tools to:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Navigate to LoRA list page
|
# Navigate to LoRA list page
|
||||||
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
|
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||||
|
|
||||||
# Wait for page to load
|
# Wait for page to load
|
||||||
wait_for(text="LoRAs", timeout=10000)
|
wait_for(text="LoRAs", timeout=10000)
|
||||||
@@ -68,9 +166,10 @@ snapshot = take_snapshot()
|
|||||||
### Pattern: Restart Server for Configuration Changes
|
### Pattern: Restart Server for Configuration Changes
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Stop current server (if running)
|
# Stop current server (if running), start with new configuration.
|
||||||
# Start with new configuration
|
# --restart only kills the E2E server this script started before (via its pidfile);
|
||||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188 --restart
|
# it refuses to blindly kill unrelated processes on the port.
|
||||||
|
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --detach
|
||||||
|
|
||||||
# Wait and refresh browser
|
# Wait and refresh browser
|
||||||
navigate_page(type="reload", ignoreCache=True)
|
navigate_page(type="reload", ignoreCache=True)
|
||||||
@@ -130,24 +229,96 @@ click(uid="modal-submit-button")
|
|||||||
wait_for(text="Success", timeout=5000)
|
wait_for(text="Success", timeout=5000)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fixture + Fresh-State Guidance
|
||||||
|
|
||||||
|
For rematch/repair E2E runs, seed the **sandboxed** `recipes_path` with hand-written fixture recipes. Rules (validated by the task-8 E2E):
|
||||||
|
|
||||||
|
1. **Filename constraint**: each file MUST be named `f"{id}.recipe.json"` **and** the in-JSON `id` field MUST equal the filename. Discovery accepts any `*.recipe.json`, but persistence resolves the path via `get_recipe_json_path` and `_save_recipe_persistently` returns `False` on a mismatch → the fixture would be counted as an error.
|
||||||
|
- `recipe-a.recipe.json` → in-JSON `"id": "recipe-a"`
|
||||||
|
2. **File format**: mirror an existing recipe JSON — top-level `id`, `file_path`, `title`, `loras`, `fingerprint`, `gen_params`; lora entries per the persistence conventions (`hash`, `file_name`, `modelVersionId`, `isDeleted`, ...).
|
||||||
|
3. **Companion image**: each recipe needs an image (e.g. a `.webp` generated with PIL) referenced by `file_path`, used for EXIF verification (`ExifUtils.append_recipe_metadata` writes a `"Recipe metadata: ..."` marker; a freshly generated `.webp` with no marker is the clean "untouched" control).
|
||||||
|
4. **autov3 three-state contract**: for L3 (autov3-only, renamed-file) fixtures the local model's `.metadata.json` sidecar MUST have the `autov3` key **ABSENT** (the "unchecked" state), NOT `""` — `""` is the TERMINAL "checked but unavailable" state that L3 deliberately skips. The scanner computes + persists `autov3` from the file header during the normal library scan (`model_scanner.py` `_process_model_file`), so the live L3 match resolves through the local autov3/hash cache; the computed-autov3 branch for unchecked items is covered by the unit suite.
|
||||||
|
5. **Fixture design for a rematch run** (mirrors the task-8 E2E):
|
||||||
|
- `recipe-a`: lora entry `isDeleted=True`, `hash` = 12-char autov3 computed from the local model (`calculate_autov3`, `py/utils/file_utils.py`), whose local model file was RENAMED after the recipe was written so `file_name` differs (proves L3 match without filename).
|
||||||
|
- `recipe-b`: parser-convention checkpoint entry (uses `id`, no `modelVersionId`) matching a local checkpoint via L2 — the local checkpoint's `.metadata.json` MUST carry civitai version data with that `id` so `version_index` contains it (L2 cannot match otherwise).
|
||||||
|
- `recipe-c`: healthy recipe (no deleted entries) → must remain untouched.
|
||||||
|
|
||||||
|
### Fresh state between entry-point runs
|
||||||
|
|
||||||
|
Each entry point (global / per-recipe / selection-bulk) must start from the same deleted state. Between runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Reset fixtures to the before-state snapshot (copy back from recipes-before/)
|
||||||
|
cp /tmp/opencode/<plan>-e2e/recipes-before/*.recipe.json /tmp/opencode/<plan>-e2e/recipes/
|
||||||
|
# 2. Clear the recipe/FTS caches so the stale in-memory/library state is gone
|
||||||
|
rm -f <repo-root>/cache/recipe/*.sqlite
|
||||||
|
rm -rf <repo-root>/cache/fts/*
|
||||||
|
# 3. Restart the server (fresh process, fresh scan)
|
||||||
|
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach
|
||||||
|
# 4. Re-verify server listening + reload the browser page
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Lifecycle
|
||||||
|
|
||||||
|
- **Detached launch is mandatory**: the standalone server dies with the shell unless launched via `setsid` (or the helper script's `--detach`). Use `setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 ... < /dev/null &`.
|
||||||
|
- **Verify with `ss -tlnp`** after every (re)start; do not proceed on a blind "server starting" message.
|
||||||
|
- **Never kill pre-existing processes** — only kill the E2E server PID you started (`start_server.py --restart` kills only PIDs it manages via its pidfile). The live ComfyUI or a stale QA Chrome must never be killed as part of cleanup unless explicitly identified as such (see Chrome troubleshooting).
|
||||||
|
- **Record your PID for cleanup**: note the PID printed/pidfile, and stop exactly that PID at the end (`kill <PID>`, then confirm with `ss -tlnp` that `{PORT}` is released).
|
||||||
|
|
||||||
|
## Chrome DevTools MCP Troubleshooting
|
||||||
|
|
||||||
|
### Stale profile lock ("browser is already running" / `list_pages` fails)
|
||||||
|
|
||||||
|
A Chrome profile can be held by a stale Chrome from a prior MCP session, which makes `list_pages` fail with "browser is already running":
|
||||||
|
|
||||||
|
1. Identify the stale Chrome — it owns the profile dir in `--user-data-dir` (e.g. `~/.config/chrome-dev-profile`). Find its process:
|
||||||
|
```bash
|
||||||
|
ps -ef | grep -i '[c]hrome.*user-data-dir'
|
||||||
|
```
|
||||||
|
2. Confirm it is a QA Chrome from a completed task (its parent is an old MCP/browser process, it is NOT the live ComfyUI server, and it is NOT your current MCP instance).
|
||||||
|
3. Kill ONLY that stale Chrome:
|
||||||
|
```bash
|
||||||
|
kill <stale-chrome-pid>
|
||||||
|
```
|
||||||
|
Never kill the live server or unrelated processes.
|
||||||
|
4. Retry `list_pages`. The current MCP will spawn a fresh browser.
|
||||||
|
|
||||||
|
### Screenshot-write restrictions
|
||||||
|
|
||||||
|
The chrome-devtools MCP may refuse to write into paths outside its configured workspace roots (e.g. the worktree `.omo/evidence/...` canonicalizing to an unmapped path). Workaround:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Save the screenshot to /tmp via the MCP
|
||||||
|
# take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
|
||||||
|
# 2. Copy it into the evidence dir from the shell
|
||||||
|
mkdir -p <repo-root>/.omo/evidence/screenshots
|
||||||
|
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cancellation Testing (KNOWN GAP)
|
||||||
|
|
||||||
|
Testing the rematch-cancel path E2E requires a run long enough to cancel mid-flight. A tiny 3-recipe fixture set completes in **seconds** — too fast to reliably cancel. The cancel path is currently **unit-covered only** (`rematch_all_recipes` cancellation tests); do not block an E2E run on cancel-path verification. If you must attempt it, you would need an artificially large/deferred fixture set to create a cancellable window — treat this as a research task, not part of the standard E2E.
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
### scripts/start_server.py
|
### scripts/start_server.py
|
||||||
|
|
||||||
Starts or restarts the LoRa Manager standalone server.
|
Starts or restarts the LoRa Manager standalone server for E2E testing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/start_server.py [--port PORT] [--restart] [--wait]
|
python scripts/start_server.py [--port PORT] [--restart] [--wait] [--timeout SECONDS] [--detach]
|
||||||
```
|
```
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
- `--port`: Server port (default: 8188)
|
- `--port`: Server port (default: 8188). The script exits early with a clear message if the port is already in use by an unrelated process.
|
||||||
- `--restart`: Kill existing server before starting
|
- `--restart`: Kill the E2E server this script previously managed (tracked via `/tmp/lora-manager-e2e-server-{PORT}.pid`) before starting. If unrelated processes still hold the port after that, the script reports them and aborts instead of killing them.
|
||||||
- `--wait`: Wait for server to be ready before exiting
|
- `--wait`: Wait for the server to be ready before exiting.
|
||||||
|
- `--timeout`: Readiness wait timeout in seconds (default: 30).
|
||||||
|
- `--detach`: Launch the server fully detached (`setsid`-style, survives shell death — REQUIRED for E2E). Default off: a normal background process that dies with the shell.
|
||||||
|
|
||||||
### scripts/wait_for_server.py
|
### scripts/wait_for_server.py
|
||||||
|
|
||||||
Polls server until ready or timeout.
|
Polls the server until ready or timeout.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
|
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
|
||||||
@@ -196,6 +367,7 @@ results = performance_stop_trace()
|
|||||||
## Cleanup
|
## Cleanup
|
||||||
|
|
||||||
Always ensure proper cleanup after tests:
|
Always ensure proper cleanup after tests:
|
||||||
1. Stop the standalone server
|
1. Stop the standalone server: `kill <recorded-pid>` (only the PID you started), then confirm `ss -tlnp | grep ':{PORT}'` is empty.
|
||||||
2. Close browser pages (keep at least one open)
|
2. Close browser pages (keep at least one open).
|
||||||
3. Clear temporary data if needed
|
3. Remove the sandbox: `rm -rf /tmp/opencode/<plan>-e2e` and `<repo-root>/settings.json` + `<repo-root>/cache` (both gitignored).
|
||||||
|
4. Re-run the real-data protection check from the SANDBOX section and record the result in your evidence.
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
Quick reference for common MCP commands used in LoRa Manager E2E testing.
|
Quick reference for common MCP commands used in LoRa Manager E2E testing.
|
||||||
|
|
||||||
|
> **Port convention**: `{PORT}` is the port chosen for the E2E run (default candidate `8188`, but only if actually free — see the SKILL.md Port Selection section; use e.g. `8199` when `8188` is occupied by a live ComfyUI). Always run against the **sandboxed** standalone server, never a live instance.
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Navigate to LoRA list page
|
# Navigate to LoRA list page
|
||||||
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
|
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||||
|
|
||||||
# Reload page with cache clear
|
# Reload page with cache clear
|
||||||
navigate_page(type="reload", ignoreCache=True)
|
navigate_page(type="reload", ignoreCache=True)
|
||||||
@@ -179,7 +181,7 @@ pages = list_pages()
|
|||||||
select_page(pageId=0, bringToFront=True)
|
select_page(pageId=0, bringToFront=True)
|
||||||
|
|
||||||
# Create new page
|
# Create new page
|
||||||
new_page(url="http://127.0.0.1:8188/loras")
|
new_page(url="http://127.0.0.1:{PORT}/loras")
|
||||||
|
|
||||||
# Close page (keep at least one open!)
|
# Close page (keep at least one open!)
|
||||||
close_page(pageId=1)
|
close_page(pageId=1)
|
||||||
@@ -261,7 +263,7 @@ drag(from_uid="draggable-item", to_uid="drop-zone")
|
|||||||
### Verify LoRA Cards Loaded
|
### Verify LoRA Cards Loaded
|
||||||
|
|
||||||
```python
|
```python
|
||||||
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
|
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||||
wait_for(text="LoRAs", timeout=10000)
|
wait_for(text="LoRAs", timeout=10000)
|
||||||
|
|
||||||
# Check if cards loaded
|
# Check if cards loaded
|
||||||
@@ -322,3 +324,37 @@ navigate_page(type="reload")
|
|||||||
errors = list_console_messages(types=["error"])
|
errors = list_console_messages(types=["error"])
|
||||||
assert len(errors) == 0, f"Console errors: {errors}"
|
assert len(errors) == 0, f"Console errors: {errors}"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Stale profile lock ("browser is already running" / `list_pages` fails)
|
||||||
|
|
||||||
|
A Chrome profile held by a stale Chrome from a prior MCP session makes `list_pages`
|
||||||
|
fail with "browser is already running". Fix:
|
||||||
|
|
||||||
|
1. Find the stale Chrome that owns the profile dir (e.g. `~/.config/chrome-dev-profile`):
|
||||||
|
```bash
|
||||||
|
ps -ef | grep -i '[c]hrome.*user-data-dir'
|
||||||
|
```
|
||||||
|
2. Confirm it is a QA Chrome from a completed task (NOT the live ComfyUI server, NOT
|
||||||
|
your current MCP instance).
|
||||||
|
3. Kill ONLY that stale Chrome (`kill <stale-pid>`), then retry `list_pages`.
|
||||||
|
|
||||||
|
### Screenshot-write restrictions
|
||||||
|
|
||||||
|
The MCP may refuse to write into paths outside its configured workspace roots
|
||||||
|
(e.g. `.omo/evidence/screenshots/` under a worktree that canonicalizes to an unmapped
|
||||||
|
path). Save the screenshot to `/tmp` via the MCP, then copy it into the evidence dir:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MCP: take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
|
||||||
|
# Shell:
|
||||||
|
mkdir -p <repo-root>/.omo/evidence/screenshots
|
||||||
|
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Time budgets & abort rule
|
||||||
|
|
||||||
|
See SKILL.md "Time Budgets & Abort Guidance": if a phase exceeds ~2x its budget or a
|
||||||
|
tool call retries 3+ times in a row, STOP and report BLOCKED with the last observed
|
||||||
|
state (server PID + `ss -tlnp`, page snapshot, last API response). Do not loop.
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
|
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
|
||||||
|
|
||||||
|
> **Run preconditions (from SKILL.md)**: every run uses the **sandboxed** standalone
|
||||||
|
> server on a free port `{PORT}` (default candidate `8188`, only if actually free — pick
|
||||||
|
> e.g. `8199` when `8188` is occupied by a live ComfyUI). Fixtures live in the sandboxed
|
||||||
|
> `recipes_path` as `f"{id}.recipe.json"` files with matching in-JSON `id`; the real user
|
||||||
|
> config and real library are never touched (record protection proof before/after).
|
||||||
|
> Abort if a phase exceeds ~2x its budget or a tool call retries 3+ times (SKILL.md
|
||||||
|
> "Time Budgets & Abort Guidance").
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
1. [LoRA List Page](#lora-list-page)
|
1. [LoRA List Page](#lora-list-page)
|
||||||
@@ -19,7 +27,7 @@ This document provides detailed test scenarios for end-to-end validation of LoRa
|
|||||||
**Objective**: Verify the LoRA list page loads correctly and displays models.
|
**Objective**: Verify the LoRA list page loads correctly and displays models.
|
||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Navigate to `http://127.0.0.1:8188/loras`
|
1. Navigate to `http://127.0.0.1:{PORT}/loras`
|
||||||
2. Wait for page title "LoRAs" to appear
|
2. Wait for page title "LoRAs" to appear
|
||||||
3. Take snapshot to verify:
|
3. Take snapshot to verify:
|
||||||
- Header with "LoRAs" title is visible
|
- Header with "LoRAs" title is visible
|
||||||
@@ -134,7 +142,7 @@ evaluate_script(function="""
|
|||||||
**Objective**: Verify recipes page loads and displays recipes.
|
**Objective**: Verify recipes page loads and displays recipes.
|
||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Navigate to `http://127.0.0.1:8188/recipes`
|
1. Navigate to `http://127.0.0.1:{PORT}/recipes`
|
||||||
2. Wait for "Recipes" title
|
2. Wait for "Recipes" title
|
||||||
3. Take snapshot
|
3. Take snapshot
|
||||||
|
|
||||||
@@ -176,7 +184,7 @@ evaluate_script(function="""
|
|||||||
**Objective**: Verify settings page displays correctly.
|
**Objective**: Verify settings page displays correctly.
|
||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Navigate to `http://127.0.0.1:8188/settings`
|
1. Navigate to `http://127.0.0.1:{PORT}/settings`
|
||||||
2. Wait for "Settings" title
|
2. Wait for "Settings" title
|
||||||
3. Take snapshot
|
3. Take snapshot
|
||||||
|
|
||||||
@@ -190,7 +198,7 @@ evaluate_script(function="""
|
|||||||
1. Navigate to settings page
|
1. Navigate to settings page
|
||||||
2. Change a setting (e.g., default view mode)
|
2. Change a setting (e.g., default view mode)
|
||||||
3. Save settings
|
3. Save settings
|
||||||
4. Restart server: `python scripts/start_server.py --restart --wait`
|
4. Restart server: `python scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach`
|
||||||
5. Refresh browser page
|
5. Refresh browser page
|
||||||
6. Navigate to settings
|
6. Navigate to settings
|
||||||
|
|
||||||
|
|||||||
@@ -8,186 +8,208 @@ This script shows how to:
|
|||||||
3. Verify functionality end-to-end
|
3. Verify functionality end-to-end
|
||||||
|
|
||||||
Note: This is a template. Actual execution requires Chrome DevTools MCP.
|
Note: This is a template. Actual execution requires Chrome DevTools MCP.
|
||||||
|
|
||||||
|
Port: pick a FREE port for the run — 8188 is commonly occupied by a live
|
||||||
|
ComfyUI (see the skill's Port Selection section). Set PORT below to e.g. 8199
|
||||||
|
when 8188 is taken. Always run against a SANDBOXED standalone server.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
# Choose the E2E port. 8188 is only the default candidate; use 8199 (or any
|
||||||
|
# free port checked with `ss -tlnp`) when 8188 is occupied by a live ComfyUI.
|
||||||
|
PORT = "8188"
|
||||||
|
|
||||||
|
|
||||||
def run_test():
|
def run_test():
|
||||||
"""Run example E2E test flow."""
|
"""Run example E2E test flow."""
|
||||||
|
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("LoRa Manager E2E Test Example")
|
print("LoRa Manager E2E Test Example")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
# Step 1: Start server
|
# Step 1: Start server (detached so it survives the shell)
|
||||||
print("\n[1/5] Starting LoRa Manager standalone server...")
|
print("\n[1/5] Starting LoRa Manager standalone server...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, "start_server.py", "--port", "8188", "--wait", "--timeout", "30"],
|
[sys.executable, "start_server.py", "--port", PORT, "--wait", "--timeout", "30", "--detach"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print(f"Failed to start server: {result.stderr}")
|
print(f"Failed to start server: {result.stderr}")
|
||||||
return 1
|
return 1
|
||||||
print("Server ready!")
|
print("Server ready!")
|
||||||
|
|
||||||
# Step 2: Open Chrome (manual step - show command)
|
# Step 2: Open Chrome (manual step - show command)
|
||||||
print("\n[2/5] Open Chrome with debug mode:")
|
print("\n[2/5] Open Chrome with debug mode:")
|
||||||
print("google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras")
|
print(
|
||||||
|
f"google-chrome --remote-debugging-port=9222 "
|
||||||
|
f"--user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras"
|
||||||
|
)
|
||||||
print("(In actual test, this would be automated via MCP)")
|
print("(In actual test, this would be automated via MCP)")
|
||||||
|
|
||||||
# Step 3: Navigate and verify page load
|
# Step 3: Navigate and verify page load
|
||||||
print("\n[3/5] Page Load Verification:")
|
print("\n[3/5] Page Load Verification:")
|
||||||
print("""
|
print(
|
||||||
|
f"""
|
||||||
MCP Commands to execute:
|
MCP Commands to execute:
|
||||||
1. navigate_page(type="url", url="http://127.0.0.1:8188/loras")
|
1. navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||||
2. wait_for(text="LoRAs", timeout=10000)
|
2. wait_for(text="LoRAs", timeout=10000)
|
||||||
3. snapshot = take_snapshot()
|
3. snapshot = take_snapshot()
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Step 4: Test search functionality
|
# Step 4: Test search functionality
|
||||||
print("\n[4/5] Search Functionality Test:")
|
print("\n[4/5] Search Functionality Test:")
|
||||||
print("""
|
print(
|
||||||
|
"""
|
||||||
MCP Commands to execute:
|
MCP Commands to execute:
|
||||||
1. fill(uid="search-input", value="test")
|
1. fill(uid="search-input", value="test")
|
||||||
2. press_key(key="Enter")
|
2. press_key(key="Enter")
|
||||||
3. wait_for(text="Results", timeout=5000)
|
3. wait_for(text="Results", timeout=5000)
|
||||||
4. result = evaluate_script(function="""
|
4. result = evaluate_script(function=`
|
||||||
() => {
|
() => {
|
||||||
const cards = document.querySelectorAll('.lora-card');
|
const cards = document.querySelectorAll('.lora-card');
|
||||||
return { count: cards.length };
|
return { count: cards.length };
|
||||||
}
|
}
|
||||||
""")
|
`)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Step 5: Verify API
|
# Step 5: Verify API
|
||||||
print("\n[5/5] API Verification:")
|
print("\n[5/5] API Verification:")
|
||||||
print("""
|
print(
|
||||||
|
"""
|
||||||
MCP Commands to execute:
|
MCP Commands to execute:
|
||||||
1. api_result = evaluate_script(function="""
|
1. api_result = evaluate_script(function=`
|
||||||
async () => {
|
async () => {
|
||||||
const response = await fetch('/loras/api/list');
|
const response = await fetch('/loras/api/list');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return { count: data.length, status: response.status };
|
return { count: data.length, status: response.status };
|
||||||
}
|
}
|
||||||
""")
|
`)
|
||||||
2. Verify api_result['status'] == 200
|
2. Verify api_result['status'] == 200
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("Test flow completed!")
|
print("Test flow completed!")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def example_restart_flow():
|
def example_restart_flow():
|
||||||
"""Example: Testing configuration change that requires restart."""
|
"""Example: Testing configuration change that requires restart."""
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("Example: Server Restart Flow")
|
print("Example: Server Restart Flow")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
print("""
|
print(
|
||||||
|
f"""
|
||||||
Scenario: Change setting and verify after restart
|
Scenario: Change setting and verify after restart
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Navigate to settings page
|
1. Navigate to settings page
|
||||||
- navigate_page(type="url", url="http://127.0.0.1:8188/settings")
|
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
|
||||||
|
|
||||||
2. Change a setting (e.g., theme)
|
2. Change a setting (e.g., theme)
|
||||||
- fill(uid="theme-select", value="dark")
|
- fill(uid="theme-select", value="dark")
|
||||||
- click(uid="save-settings-button")
|
- click(uid="save-settings-button")
|
||||||
|
|
||||||
3. Restart server
|
3. Restart server
|
||||||
- subprocess.run([python, "start_server.py", "--restart", "--wait"])
|
- subprocess.run([python, "start_server.py", "--port", "{PORT}", "--restart", "--wait", "--detach"])
|
||||||
|
|
||||||
4. Refresh browser
|
4. Refresh browser
|
||||||
- navigate_page(type="reload", ignoreCache=True)
|
- navigate_page(type="reload", ignoreCache=True)
|
||||||
- wait_for(text="LoRAs", timeout=15000)
|
- wait_for(text="LoRAs", timeout=15000)
|
||||||
|
|
||||||
5. Verify setting persisted
|
5. Verify setting persisted
|
||||||
- navigate_page(type="url", url="http://127.0.0.1:8188/settings")
|
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
|
||||||
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
|
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
|
||||||
- assert theme == "dark"
|
- assert theme == "dark"
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def example_modal_interaction():
|
def example_modal_interaction():
|
||||||
"""Example: Testing modal dialog interaction."""
|
"""Example: Testing modal dialog interaction."""
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("Example: Modal Dialog Interaction")
|
print("Example: Modal Dialog Interaction")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
print("""
|
print(
|
||||||
|
"""
|
||||||
Scenario: Add new LoRA via modal
|
Scenario: Add new LoRA via modal
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Open modal
|
1. Open modal
|
||||||
- click(uid="add-lora-button")
|
- click(uid="add-lora-button")
|
||||||
- wait_for(text="Add LoRA", timeout=3000)
|
- wait_for(text="Add LoRA", timeout=3000)
|
||||||
|
|
||||||
2. Fill form
|
2. Fill form
|
||||||
- fill_form(elements=[
|
- fill_form(elements=[
|
||||||
{"uid": "lora-name", "value": "Test Character"},
|
{"uid": "lora-name", "value": "Test Character"},
|
||||||
{"uid": "lora-path", "value": "/models/test.safetensors"},
|
{"uid": "lora-path", "value": "/models/test.safetensors"},
|
||||||
])
|
])
|
||||||
|
|
||||||
3. Submit
|
3. Submit
|
||||||
- click(uid="modal-submit-button")
|
- click(uid="modal-submit-button")
|
||||||
|
|
||||||
4. Verify success
|
4. Verify success
|
||||||
- wait_for(text="Successfully added", timeout=5000)
|
- wait_for(text="Successfully added", timeout=5000)
|
||||||
- snapshot = take_snapshot()
|
- snapshot = take_snapshot()
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def example_network_monitoring():
|
def example_network_monitoring():
|
||||||
"""Example: Network request monitoring."""
|
"""Example: Network request monitoring."""
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("Example: Network Request Monitoring")
|
print("Example: Network Request Monitoring")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
print("""
|
print(
|
||||||
|
f"""
|
||||||
Scenario: Verify API calls during user interaction
|
Scenario: Verify API calls during user interaction
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
1. Clear network log (implicit on navigation)
|
1. Clear network log (implicit on navigation)
|
||||||
- navigate_page(type="url", url="http://127.0.0.1:8188/loras")
|
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||||
|
|
||||||
2. Perform action that triggers API call
|
2. Perform action that triggers API call
|
||||||
- fill(uid="search-input", value="character")
|
- fill(uid="search-input", value="character")
|
||||||
- press_key(key="Enter")
|
- press_key(key="Enter")
|
||||||
|
|
||||||
3. List network requests
|
3. List network requests
|
||||||
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
|
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
|
||||||
|
|
||||||
4. Find search API call
|
4. Find search API call
|
||||||
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
|
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
|
||||||
- assert len(search_requests) > 0, "Search API was not called"
|
- assert len(search_requests) > 0, "Search API was not called"
|
||||||
|
|
||||||
5. Get request details
|
5. Get request details
|
||||||
- if search_requests:
|
- if search_requests:
|
||||||
details = get_network_request(reqid=search_requests[0]["reqid"])
|
details = get_network_request(reqid=search_requests[0]["reqid"])
|
||||||
- Verify request method, response status, etc.
|
- Verify request method, response status, etc.
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("LoRa Manager E2E Test Examples\n")
|
print("LoRa Manager E2E Test Examples\n")
|
||||||
print("This script demonstrates E2E testing patterns.\n")
|
print("This script demonstrates E2E testing patterns.\n")
|
||||||
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
|
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
|
||||||
|
|
||||||
run_test()
|
run_test()
|
||||||
example_restart_flow()
|
example_restart_flow()
|
||||||
example_modal_interaction()
|
example_modal_interaction()
|
||||||
example_network_monitoring()
|
example_network_monitoring()
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("All examples shown!")
|
print("All examples shown!")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|||||||
@@ -1,15 +1,78 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Start or restart LoRa Manager standalone server for E2E testing.
|
Start or restart LoRa Manager standalone server for E2E testing.
|
||||||
|
|
||||||
|
Backward-compatible CLI: --port, --restart, --wait, --timeout all work as before.
|
||||||
|
New options: --detach (setsid-style fully detached launch, survives shell death).
|
||||||
|
|
||||||
|
Safety rules implemented here:
|
||||||
|
- Never kill processes the script did not start. The script tracks the PIDs it
|
||||||
|
manages in a pidfile (/tmp/lora-manager-e2e-server-{PORT}.pid).
|
||||||
|
- If the port is held by an unrelated process (e.g. a live ComfyUI) the script
|
||||||
|
reports the conflict and exits early instead of killing it.
|
||||||
|
- --restart only kills managed PIDs; if unrelated processes still hold the port
|
||||||
|
afterwards, the script reports them and aborts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import socket
|
|
||||||
import signal
|
PIDFILE_PREFIX = "/tmp/lora-manager-e2e-server"
|
||||||
import os
|
|
||||||
|
|
||||||
|
def pidfile_path(port: int) -> str:
|
||||||
|
"""Path of the pidfile that records PIDs this script started for a port."""
|
||||||
|
return f"{PIDFILE_PREFIX}-{port}.pid"
|
||||||
|
|
||||||
|
|
||||||
|
def read_managed_pids(port: int) -> list[int]:
|
||||||
|
"""Read PIDs this script previously managed for the port (may be stale)."""
|
||||||
|
path = pidfile_path(port)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
return [int(line.strip()) for line in fh if line.strip().isdigit()]
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def write_managed_pids(port: int, pids: list[int]) -> None:
|
||||||
|
"""Record PIDs this script manages for the port."""
|
||||||
|
try:
|
||||||
|
with open(pidfile_path(port), "w", encoding="utf-8") as fh:
|
||||||
|
for pid in pids:
|
||||||
|
fh.write(f"{pid}\n")
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Warning: could not write pidfile for port {port}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_managed_pids(port: int) -> None:
|
||||||
|
"""Remove the pidfile for the port (no longer managed)."""
|
||||||
|
path = pidfile_path(port)
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Warning: could not remove pidfile {path}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def process_alive(pid: int) -> bool:
|
||||||
|
"""Return True if a process with the given pid exists."""
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True # exists but owned by someone else
|
||||||
|
|
||||||
|
|
||||||
def find_server_process(port: int) -> list[int]:
|
def find_server_process(port: int) -> list[int]:
|
||||||
@@ -19,7 +82,7 @@ def find_server_process(port: int) -> list[int]:
|
|||||||
["lsof", "-ti", f":{port}"],
|
["lsof", "-ti", f":{port}"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False
|
check=False,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
return [int(pid) for pid in result.stdout.strip().split("\n") if pid]
|
return [int(pid) for pid in result.stdout.strip().split("\n") if pid]
|
||||||
@@ -30,7 +93,7 @@ def find_server_process(port: int) -> list[int]:
|
|||||||
["netstat", "-tlnp"],
|
["netstat", "-tlnp"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False
|
check=False,
|
||||||
)
|
)
|
||||||
pids = []
|
pids = []
|
||||||
for line in result.stdout.split("\n"):
|
for line in result.stdout.split("\n"):
|
||||||
@@ -49,30 +112,48 @@ def find_server_process(port: int) -> list[int]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def kill_server(port: int) -> None:
|
def describe_processes(pids: list[int]) -> str:
|
||||||
"""Kill processes using the specified port."""
|
"""Human-readable description of a pid list (pid + command line)."""
|
||||||
pids = find_server_process(port)
|
descriptions = []
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
|
cmdline = ""
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as fh:
|
||||||
|
raw = fh.read().replace(b"\x00", b" ").decode("utf-8", "replace")
|
||||||
|
cmdline = raw.strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
descriptions.append(f"pid {pid}{' (' + cmdline + ')' if cmdline else ''}")
|
||||||
|
return ", ".join(descriptions) if descriptions else "none"
|
||||||
|
|
||||||
|
|
||||||
|
def kill_pids(pids: list[int], what: str) -> None:
|
||||||
|
"""Send SIGTERM (then SIGKILL) to the given PIDs, only after reporting."""
|
||||||
|
for pid in pids:
|
||||||
|
print(f"Sent SIGTERM to {what} pid {pid}")
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
print(f"Sent SIGTERM to process {pid}")
|
|
||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Wait for processes to terminate
|
# Wait for processes to terminate
|
||||||
time.sleep(1)
|
deadline = time.time() + 5
|
||||||
|
while time.time() < deadline:
|
||||||
|
if not any(process_alive(pid) for pid in pids):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
# Force kill if still running
|
# Force kill if still running
|
||||||
pids = find_server_process(port)
|
|
||||||
for pid in pids:
|
for pid in pids:
|
||||||
try:
|
if process_alive(pid):
|
||||||
os.kill(pid, signal.SIGKILL)
|
try:
|
||||||
print(f"Sent SIGKILL to process {pid}")
|
os.kill(pid, signal.SIGKILL)
|
||||||
except ProcessLookupError:
|
print(f"Sent SIGKILL to {what} pid {pid}")
|
||||||
pass
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def is_server_ready(port: int, timeout: float = 0.5) -> bool:
|
def is_server_ready(port: int, timeout: float = 2.0) -> bool:
|
||||||
"""Check if server is accepting connections."""
|
"""Check if server is accepting connections."""
|
||||||
try:
|
try:
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
||||||
@@ -84,9 +165,15 @@ def is_server_ready(port: int, timeout: float = 0.5) -> bool:
|
|||||||
def wait_for_server(port: int, timeout: int = 30) -> bool:
|
def wait_for_server(port: int, timeout: int = 30) -> bool:
|
||||||
"""Wait for server to become ready."""
|
"""Wait for server to become ready."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
last_report = 0.0
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
if is_server_ready(port):
|
if is_server_ready(port):
|
||||||
return True
|
return True
|
||||||
|
# Report progress every ~5s so a slow boot is visible, not silent.
|
||||||
|
elapsed = time.time() - start
|
||||||
|
if elapsed - last_report >= 5:
|
||||||
|
print(f" ...still waiting ({int(elapsed)}s/{timeout}s)")
|
||||||
|
last_report = elapsed
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -99,68 +186,148 @@ def main() -> int:
|
|||||||
"--port",
|
"--port",
|
||||||
type=int,
|
type=int,
|
||||||
default=8188,
|
default=8188,
|
||||||
help="Server port (default: 8188)"
|
help="Server port (default: 8188)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--restart",
|
"--restart",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Kill existing server before starting"
|
help="Kill the E2E server previously managed by this script for the port "
|
||||||
|
"(tracked via pidfile) before starting; refuse to kill unrelated processes",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wait",
|
"--wait",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Wait for server to be ready before exiting"
|
help="Wait for server to be ready before exiting",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--timeout",
|
"--timeout",
|
||||||
type=int,
|
type=int,
|
||||||
default=30,
|
default=30,
|
||||||
help="Timeout for waiting (default: 30)"
|
help="Timeout for waiting (default: 30)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--detach",
|
||||||
|
action="store_true",
|
||||||
|
help="Launch the server fully detached (setsid-style) so it survives shell "
|
||||||
|
"death. REQUIRED for E2E: a plain background process dies with the shell",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Get project root (parent of .agents directory)
|
# Get project root (parent of .agents directory)
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
skill_dir = os.path.dirname(script_dir)
|
skill_dir = os.path.dirname(script_dir)
|
||||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
|
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
|
||||||
|
|
||||||
# Restart if requested
|
managed_pids = read_managed_pids(args.port)
|
||||||
|
|
||||||
|
# Restart if requested: kill ONLY managed PIDs.
|
||||||
if args.restart:
|
if args.restart:
|
||||||
print(f"Killing existing server on port {args.port}...")
|
alive_managed = [pid for pid in managed_pids if process_alive(pid)]
|
||||||
kill_server(args.port)
|
if alive_managed:
|
||||||
|
print(
|
||||||
|
f"Killing E2E server previously started by this script on port "
|
||||||
|
f"{args.port} ({describe_processes(alive_managed)})..."
|
||||||
|
)
|
||||||
|
kill_pids(alive_managed, "managed E2E server")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"No live managed E2E server for port {args.port} "
|
||||||
|
f"(pidfile: {pidfile_path(args.port)})"
|
||||||
|
)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
# Refuse to kill anything the script did not manage.
|
||||||
# Check if already running
|
remaining = find_server_process(args.port)
|
||||||
if is_server_ready(args.port):
|
if remaining:
|
||||||
print(f"Server already running on port {args.port}")
|
print(
|
||||||
return 0
|
f"ERROR: port {args.port} is still held by process(es) this script "
|
||||||
|
f"did not start: {describe_processes(remaining)}."
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"These may be unrelated (e.g. a live ComfyUI). The script will NOT "
|
||||||
|
"kill them. Pick a different --port, or stop them manually if you "
|
||||||
|
"are certain they are stale E2E servers."
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
clear_managed_pids(args.port)
|
||||||
|
|
||||||
|
# Port conflict check before starting: never blind-kill.
|
||||||
|
port_pids = find_server_process(args.port)
|
||||||
|
if port_pids:
|
||||||
|
alive_managed = [pid for pid in port_pids if pid in managed_pids]
|
||||||
|
unmanaged = [pid for pid in port_pids if pid not in managed_pids]
|
||||||
|
if alive_managed and not unmanaged:
|
||||||
|
print(
|
||||||
|
f"Server already running on port {args.port} "
|
||||||
|
f"({describe_processes(alive_managed)}, started by this script). "
|
||||||
|
f"Use --restart to recycle it."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
print(
|
||||||
|
f"ERROR: port {args.port} is already in use by process(es): "
|
||||||
|
f"{describe_processes(port_pids)}."
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"This is likely an unrelated process (e.g. a live ComfyUI holding 8188). "
|
||||||
|
"The script will NOT kill it. Pick a free port with --port, e.g. 8199."
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
# Start server
|
# Start server
|
||||||
print(f"Starting LoRa Manager standalone server on port {args.port}...")
|
print(f"Starting LoRa Manager standalone server on port {args.port}...")
|
||||||
cmd = [sys.executable, "standalone.py", "--port", str(args.port)]
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
# Start in background
|
"standalone.py",
|
||||||
process = subprocess.Popen(
|
"--host",
|
||||||
cmd,
|
"127.0.0.1",
|
||||||
cwd=project_root,
|
"--port",
|
||||||
stdout=subprocess.PIPE,
|
str(args.port),
|
||||||
stderr=subprocess.PIPE,
|
]
|
||||||
start_new_session=True
|
|
||||||
)
|
if args.detach:
|
||||||
|
# Fully detached launch: new session (setsid), no controlling terminal,
|
||||||
print(f"Server process started with PID {process.pid}")
|
# stdin from /dev/null, stdout/stderr to a log file. Survives the shell.
|
||||||
|
log_dir = os.path.join(script_dir, "logs")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
log_path = os.path.join(log_dir, f"server-{args.port}.log")
|
||||||
|
with open(log_path, "ab") as log_fh:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=project_root,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=log_fh,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
start_new_session=True,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
print(f"Detached server process started with PID {process.pid} (setsid)")
|
||||||
|
print(f"Log: {log_path}")
|
||||||
|
else:
|
||||||
|
# Plain background process (legacy behavior): dies with the shell.
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=project_root,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
print(f"Server process started with PID {process.pid}")
|
||||||
|
print(
|
||||||
|
"NOTE: not detached — this process dies when the launching shell exits. "
|
||||||
|
"For E2E use --detach."
|
||||||
|
)
|
||||||
|
|
||||||
|
write_managed_pids(args.port, [process.pid])
|
||||||
|
|
||||||
# Wait for ready if requested
|
# Wait for ready if requested
|
||||||
if args.wait:
|
if args.wait:
|
||||||
print(f"Waiting for server to be ready (timeout: {args.timeout}s)...")
|
print(f"Waiting for server to be ready (timeout: {args.timeout}s)...")
|
||||||
if wait_for_server(args.port, args.timeout):
|
if wait_for_server(args.port, args.timeout):
|
||||||
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
|
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
|
||||||
return 0
|
return 0
|
||||||
else:
|
print(f"Timeout waiting for server on port {args.port}")
|
||||||
print(f"Timeout waiting for server")
|
return 1
|
||||||
return 1
|
|
||||||
|
|
||||||
print(f"Server starting at http://127.0.0.1:{args.port}/loras")
|
print(f"Server starting at http://127.0.0.1:{args.port}/loras")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Wait for LoRa Manager server to become ready.
|
Wait for LoRa Manager server to become ready.
|
||||||
|
|
||||||
|
Timeout is configurable via --timeout (default 30s); the script polls the port
|
||||||
|
until the server accepts connections or the timeout expires.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
def is_server_ready(port: int, timeout: float = 0.5) -> bool:
|
def is_server_ready(port: int, timeout: float = 2.0) -> bool:
|
||||||
"""Check if server is accepting connections."""
|
"""Check if server is accepting connections."""
|
||||||
try:
|
try:
|
||||||
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
||||||
@@ -21,9 +26,15 @@ def is_server_ready(port: int, timeout: float = 0.5) -> bool:
|
|||||||
def wait_for_server(port: int, timeout: int = 30) -> bool:
|
def wait_for_server(port: int, timeout: int = 30) -> bool:
|
||||||
"""Wait for server to become ready."""
|
"""Wait for server to become ready."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
last_report = 0.0
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
if is_server_ready(port):
|
if is_server_ready(port):
|
||||||
return True
|
return True
|
||||||
|
# Report progress every ~5s so a slow boot is visible, not silent.
|
||||||
|
elapsed = time.time() - start
|
||||||
|
if elapsed - last_report >= 5:
|
||||||
|
print(f" ...still waiting ({int(elapsed)}s/{timeout}s)")
|
||||||
|
last_report = elapsed
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -36,25 +47,24 @@ def main() -> int:
|
|||||||
"--port",
|
"--port",
|
||||||
type=int,
|
type=int,
|
||||||
default=8188,
|
default=8188,
|
||||||
help="Server port (default: 8188)"
|
help="Server port (default: 8188)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--timeout",
|
"--timeout",
|
||||||
type=int,
|
type=int,
|
||||||
default=30,
|
default=30,
|
||||||
help="Timeout in seconds (default: 30)"
|
help="Timeout in seconds (default: 30)",
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
print(f"Waiting for server on port {args.port} (timeout: {args.timeout}s)...")
|
print(f"Waiting for server on port {args.port} (timeout: {args.timeout}s)...")
|
||||||
|
|
||||||
if wait_for_server(args.port, args.timeout):
|
if wait_for_server(args.port, args.timeout):
|
||||||
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
|
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
|
||||||
return 0
|
return 0
|
||||||
else:
|
print(f"Timeout: Server not ready after {args.timeout}s")
|
||||||
print(f"Timeout: Server not ready after {args.timeout}s")
|
return 1
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ model_cache/
|
|||||||
reasonix.toml
|
reasonix.toml
|
||||||
.reasonix/
|
.reasonix/
|
||||||
.codegraph/
|
.codegraph/
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# Vue widgets development cache (but keep build output)
|
# Vue widgets development cache (but keep build output)
|
||||||
vue-widgets/node_modules/
|
vue-widgets/node_modules/
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
slug: undo-delete-staging
|
||||||
|
status: drafting
|
||||||
|
intent: clear
|
||||||
|
review_required: false
|
||||||
|
pending-action: write .omo/plans/undo-delete-staging.md
|
||||||
|
approach: "Option B: delayed physical deletion with Undo. Backend: same-volume rename to per-root staging dir (.lm-pending-delete/) [updated 2026-08: model staging moved to a SIBLING dir inside each deleted model's own folder — see 'Symlink fix (2026-08)' under Decisions] + manifest JSON (batch_id, expires_at, staged->original map) + purge (30s TTL timer + startup sweep + opportunistic) + undo-delete endpoint + settings toggle 'skip undo'. Small files (recipes: JSON+preview) copy to global staging under settings dir instead of rename. Frontend: extend toast system with action button + 30s countdown; delete flows (single model / recipe / bulk / duplicates) consume batch_id from delete response and show Undo toast; expired undo -> 'undo expired' toast. Plus confirm-modal friction (C-friction, NO type-to-confirm): delete button delay-activation 1.5s + modal shows file size 'will free X GB' + Cancel gets initial focus. i18n keys + sync_translation_keys.py."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Draft: undo-delete-staging
|
||||||
|
|
||||||
|
## Components (topology ledger)
|
||||||
|
<!-- Lock the SHAPE before depth. One row per top-level component that can succeed or fail independently. -->
|
||||||
|
<!-- id | outcome (one line) | status: active|deferred | evidence path -->
|
||||||
|
- backend staging module (stage/purge/undo + manifest + per-volume dir resolution) | new module, active | pending exploration: model_lifecycle_service.py delete_model / delete_model_artifacts
|
||||||
|
- delete endpoints return batch_id (model/recipe/bulk/duplicates) | active | pending exploration: handlers + response shapes
|
||||||
|
- undo-delete HTTP endpoint + route registration | active | pending exploration: route registrar pattern
|
||||||
|
- purge scheduling (30s timer + startup sweep + opportunistic) | active | pending exploration: app on_startup hooks
|
||||||
|
- settings toggle "skip undo window" | active | pending exploration: settings service read pattern
|
||||||
|
- frontend toast extension (action button + countdown) | active | pending exploration: showToast impl
|
||||||
|
- frontend delete flows consume batch_id + Undo toast | active | pending exploration: call sites
|
||||||
|
- confirm-modal friction (delay-activate + size display + cancel focus) | active | pending exploration: modal focus behavior
|
||||||
|
- i18n keys + sync_translation_keys.py | active | known
|
||||||
|
|
||||||
|
## Open assumptions (announced defaults)
|
||||||
|
<!-- Record any default you adopt instead of asking, so the user can veto it at the gate. -->
|
||||||
|
<!-- assumption | adopted default | rationale | reversible? -->
|
||||||
|
- Undo window TTL = 30s | 30s balances space-freeing intent vs accident recovery | yes (constant)
|
||||||
|
- Staging dir name: `.lm-pending-delete/` under each model root; recipes: `{settings_dir}/.lm-pending-delete/` | hidden, same-volume [updated 2026-08: same-volume is now guaranteed by sibling staging inside the model's own folder, not by the root location], consistent | yes
|
||||||
|
- Staging failure falls back to existing hard delete | user intent is delete; staging is best-effort; hard delete likely fails identically under same conditions | yes
|
||||||
|
- Purge on startup uses expires_at (not purge-all) so a <30s restart with live tab can still undo | robust, matches client-side timer | yes
|
||||||
|
- Settings toggle label: "Delete permanently immediately (skip undo window)" | power users freeing space | yes
|
||||||
|
- C-friction: delete button enabled after 1.5s + modal shows freed size; NO type-to-confirm (user vetoed) | user explicitly rejected type-to-confirm | n/a
|
||||||
|
- Bulk/duplicates delete: one batch id for whole action, one undo restores all | simplest consistent semantics | yes
|
||||||
|
|
||||||
|
## Findings (cited - path:lines)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `delete_model_artifacts` (py/services/model_lifecycle_service.py:19-48) = physical delete via os.remove; patterns: main file + `{name}.metadata.json` + PREVIEW_EXTENSIONS (py/utils/constants.py:22-37). ALSO called by ModelScanner.bulk_delete_models (py/services/model_scanner.py:2221) - single swap point covers bulk models.
|
||||||
|
- `ModelLifecycleService.delete_model` (model_lifecycle_service.py:101-154): fetches `cached_entry` (111-116) - SNAPSHOT available for cache restore; after delete: cache.raw_data removal + resort + bump_cache_version (136-143), `_hash_index.remove_by_path` (145-146), `_sync_update_for_model` (148; update-service only, no recipe JSON rewrites - recipe refs are hash-based, re-resolve on restore), `_persist_current_cache` (150-152), returns `{"success": True, "deleted_files": [...]}` (154).
|
||||||
|
- Handler `delete_model` (py/routes/handlers/model_handlers.py:478-492): POST /api/lm/{prefix}/delete; response passthrough; `_broadcast_models_changed()` (57-74) after success; 400 `{"success":false,"error"}`; 500 plain text.
|
||||||
|
- Recipe delete: handler (recipe_handlers.py:1422-1438) DELETE /api/lm/recipe/{recipe_id} -> persistence_service.delete_recipe (py/services/recipes/persistence_service.py:193-209): os.remove(recipe_json_path) + os.remove(image_path) (204-206), recipe_scanner.remove_recipe (208), returns `{"success": true, "message": ...}`. PersistenceResult dataclass (20-25).
|
||||||
|
- Bulk models: POST /api/lm/{prefix}/bulk-delete (model_route_registrar.py:39) -> handler (model_handlers.py:974-994) -> lifecycle_service.bulk_delete_models (model_lifecycle_service.py:308-318) -> scanner.bulk_delete_models (model_scanner.py:2181-2269) which calls delete_model_artifacts per file (2221) + `_batch_update_cache_for_deleted_models` (2271-2335); response `{"success","status","total_deleted","total_attempted","cache_updated","results"}` (2254-2269).
|
||||||
|
- Bulk recipes: POST /api/lm/recipes/bulk-delete (recipe_route_registrar.py:50) -> handler (recipe_handlers.py:1554-1573) -> persistence_service.bulk_delete (persistence_service.py:439-482): per-id os.remove x2 (464-466), recipe_scanner.bulk_remove (472); response `{"success","deleted","failed","total_deleted","total_failed"}` (474-482).
|
||||||
|
- Duplicates: NO dedicated delete endpoints (find-only: GET /api/lm/{prefix}/find-duplicates model_route_registrar.py:59, GET /api/lm/recipes/find-duplicates recipe_route_registrar.py:49). Duplicate deletion reuses bulk-delete endpoints.
|
||||||
|
- Startup hooks: lora_manager.py:183-187 `app.on_startup.append(lambda app: cls._initialize_services())` (ComfyUI mode, app = PromptServer.instance.app at :78); standalone.py:370-374 same (StandaloneLoraManager.add_routes). Background tasks: `asyncio.create_task(name=...)` (lora_manager.py:224-239; recipe_handlers.py:793). Singleton+asyncio.Lock pattern: model_scanner.py:40-63.
|
||||||
|
- Settings: DEFAULT_SETTINGS (py/services/settings_manager.py:57-119), `get(key, default)` (1390-1392), get_settings_manager() (2215-2228), reset_settings_manager() (2231). Typed-bool getter example: get_skip_previously_downloaded_model_versions (1253-1262). Handlers: base_model_routes.py:70, base_recipe_routes.py:54.
|
||||||
|
- Model roots: ModelScanner.get_model_roots base NotImplementedError (model_scanner.py:1073-1075); impls lora_scanner.py:31-45, checkpoint_scanner.py:428-441, embedding_scanner.py:24-36. `_find_root_for_file(file_path)` (model_scanner.py:1108-1124) returns containing root - for per-root staging dir computation [updated 2026-08: staging no longer uses the containing root; batches are siblings inside the model's own folder]. Business-path rule (AGENTS.md): use os.path.abspath, never realpath, for staging/undo routing.
|
||||||
|
- Cache restore methods: ModelCache has raw_data + resort (conftest mocks: tests/conftest.py:144-154); ModelHashIndex.add_entry(sha256, file_path, autov3) (py/services/model_hash_index.py:16); RecipeScanner.add_recipe(recipe_data) (recipe_scanner.py:2136) -> recipe_cache.add_recipe (recipe_cache.py:64). No single-file incremental model rescan - use snapshot restore instead of rescan.
|
||||||
|
- Route registrar: model_route_registrar.py:177 add_route(method, path, handler), :180 add_prefixed_route - undo endpoint can be a non-prefixed route via add_route.
|
||||||
|
- Tests: tests/services/test_model_lifecycle_service.py (inline tmp_path files, per-test stub scanners ScannerForDelete/VersionAwareScanner etc); conftest MockScanner/MockCache/MockHashIndex (tests/conftest.py:134-212); integration fixtures tests/integration/conftest.py; lifecycle hook tests tests/routes/test_lora_manager_lifecycle.py:177-178, tests/standalone/test_standalone_server.py:83-84.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- 5 delete call sites:
|
||||||
|
a) Single model: static/js/utils/modalUtils.js confirmDelete (27-42) -> getModelApiClient().deleteModel(path); ignores return.
|
||||||
|
b) Recipe single: static/js/components/RecipeCard.js confirmDeleteRecipe (405-449) - RAW fetch DELETE /api/lm/recipe/{id}, checks only response.ok, showToast toast.recipes.deletedSuccessfully, state.virtualScroller.removeItemByFilePath.
|
||||||
|
c) Bulk: static/js/managers/BulkManager.js confirmBulkDelete (633-672) -> getActiveApiClient() (134-142) -> bulkDeleteModels(filePaths); reads result.cancelled/success/deleted_count/error.
|
||||||
|
d) Recipe duplicates: static/js/components/DuplicatesManager.js confirmDeleteDuplicates (457-494) - RAW fetch POST /api/lm/recipes/bulk-delete, reads data.success/data.total_deleted, exitDuplicateMode().
|
||||||
|
e) Model duplicates: static/js/components/ModelDuplicatesManager.js confirmDeleteDuplicates (710-776) - RAW fetch POST /api/lm/{type}/bulk-delete, reads data.total_deleted, then resetAndReload(true) + find-duplicates re-check.
|
||||||
|
Bonus: static/js/components/shared/ModelVersionsTab.js:1136-1144 client.deleteModel (ignores return).
|
||||||
|
- API clients: BaseModelApiClient.deleteModel (static/js/api/baseModelApi.js:184-216) returns true/false, shows its own toasts, does removeItemByFilePath inside; bulkDeleteModels (1591-1642) returns {success, deleted_count, failed_count, errors} or {success:false, cancelled:true}; RecipeSidebarApiClient.bulkDeleteModels (recipeApi.js:623-664) returns {success, deleted_count: total_deleted, ...}. Endpoint map apiConfig.js:56,64.
|
||||||
|
- Toast: showToast(key, params={}, type='info', fallback=null) (static/js/utils/uiHelpers.js:136-193) - textContent only, NO action/button support; durations 2000/5000ms; CSS static/css/components/toast.css (.toast flex gap:12px - button can be added). Closest action pattern: bannerService.registerBanner actions array + onRegister (static/js/managers/BannerService.js; used uiHelpers.js:18-57).
|
||||||
|
- i18n: locales/en.json delete keys (1303-1314 bulkDelete, 1945-1948 recipes, 1987-1991 models, 2124-2130 duplicates, 2166-2170 toast.api); t()/interpolate (static/js/i18n/index.js:193-248); translate wrapper (utils/i18nHelpers.js:13-23); sync script scripts/sync_translation_keys.py (en reference, [TODO: Translate] placeholders).
|
||||||
|
- Refresh after undo: recipes -> window.recipeManager.loadRecipes(true) (recipes.js:359; used by FilterManager.js:752 etc) or refreshRecipes (recipeApi.js:308); models -> resetAndReload(true) from modelApiFactory (used by ModelDuplicatesManager.js:740).
|
||||||
|
- Size for modal: card.dataset.file_size (ModelCard.js:467), formatFileSize (ModelModal.js:615).
|
||||||
|
- Tests: tests/frontend/utils/uiHelpers.dom.test.js (toast), api/recipeApi.bulk.test.js, components/duplicatesManager.test.js, components/modelDuplicatesManager.test.js, pages/*Page.test.js, i18n tests tests/i18n/test_i18n.py.
|
||||||
|
|
||||||
|
## Decisions (with rationale)
|
||||||
|
|
||||||
|
1. Same-volume rename staging for model files (atomic, no copy cost for multi-GB files); cross-volume rename forbidden. [CORRECTED 2026-08: "same-volume because under the containing root" was only true for plain directories — nested symlinked subdirs could cross volumes. Superseded by sibling staging: `.lm-pending-delete/<batch_id>/` inside the deleted model's own folder makes stage/undo same-device by construction; see "Symlink fix (2026-08)" below.]
|
||||||
|
2. Copy-to-global-staging for recipes (small files; avoids recipe JSON vs preview image cross-volume problem).
|
||||||
|
3. Manifest JSON files are the only state - no DB changes. Manifest includes model cached_entry snapshot for exact cache restore (no rescan needed).
|
||||||
|
4. Undo endpoint returns restored paths; expired batch -> 404-style error -> frontend 'undo expired' toast.
|
||||||
|
5. Skip-undo setting honored server-side (no batch_id in response -> no undo toast client-side).
|
||||||
|
6. Staging failure falls back to existing hard delete (best-effort undo, never blocks delete).
|
||||||
|
7. Undo window TTL = 30s constant (PENDING_DELETE_TTL_SECONDS); startup sweep uses expires_at (survives restart; browser-tab timer survives).
|
||||||
|
8. Purge triple-trigger: per-batch asyncio timer task + on_startup sweep + opportunistic purge at each stage/undo.
|
||||||
|
9. Frontend: new showActionToast (keep showToast signature untouched; extract shared createToastElement/appendToast internals); undo click -> shared handleUndoDelete(batchId, refreshFn); full list refresh after undo (recipes: window.recipeManager.loadRecipes(true); models: resetAndReload(true)).
|
||||||
|
10. C-friction wave (NO type-to-confirm - user vetoed): delete buttons delay-activate 1.5s after modal open, initial focus on Cancel, model delete modal gains "permanently deleted from disk" warning + file size display (card.dataset.file_size + formatFileSize).
|
||||||
|
11. Model cache restore on undo: append snapshot to cache.raw_data (dedupe by file_path) + resort + bump_cache_version + _persist_current_cache + _hash_index.add_entry + _broadcast_models_changed. Recipe restore: copy back files + recipe_scanner.add_recipe(recipe_data loaded from restored JSON).
|
||||||
|
|
||||||
|
### Symlink fix (2026-08)
|
||||||
|
|
||||||
|
Post-execution addendum (plan `.omo/plans/undo-delete-symlink-fix.md`, commits 5fd4946b / 0c00ee22):
|
||||||
|
|
||||||
|
12. Model staging moved from `<model_root>/.lm-pending-delete/<batch_id>/` to `<model_dir>/.lm-pending-delete/<batch_id>/` (sibling of the model artifacts, inside the deleted model's own folder). Stage/undo renames are same-device BY CONSTRUCTION — EXDEV is impossible even when the business path traverses nested symlinks to other volumes (the decision-1 "containing root" guarantee covered only plain directories). EXDEV remains possible only for cross-volume merges, which keep the batch_ids-array fallback. Accepted edge: deleting the model's whole FOLDER during the 30s window destroys that batch (undo returns 404). Batch discovery uses an in-memory registry (`_known_batch_dirs`) with a startup reconciliation scan (`purge_expired(scan_roots=True)`) covering restarts and crash leftovers. Recipe batches unchanged (copy-based settings-dir staging with the `_restore_file` EXDEV fallback).
|
||||||
|
|
||||||
|
## Scope IN
|
||||||
|
|
||||||
|
- Model single delete (model_handlers delete_model / model_lifecycle_service)
|
||||||
|
- Recipe delete (recipe_handlers delete_recipe / persistence_service)
|
||||||
|
- Bulk delete (models scanner + recipes persistence) + duplicates (reuse bulk endpoints)
|
||||||
|
- Undo endpoint POST /api/lm/undo-delete (models + recipes, one batch space)
|
||||||
|
- Purge: timer + startup sweep + opportunistic
|
||||||
|
- Settings toggle delete_undo_enabled + settings page checkbox
|
||||||
|
- Frontend: showActionToast + all 5 delete flows + shared undo handler
|
||||||
|
- C-friction modal changes (delay-activate + cancel focus + warning copy + size display)
|
||||||
|
- i18n keys + sync_translation_keys.py
|
||||||
|
- Backend + frontend tests
|
||||||
|
|
||||||
|
## Scope OUT (Must NOT have)
|
||||||
|
|
||||||
|
- NO type-to-confirm / hold-to-confirm friction (user vetoed)
|
||||||
|
- NO OS trash integration (send2trash) in this iteration
|
||||||
|
- NO persistent recycle-bin UI (no trash browsing page)
|
||||||
|
- NO changes to exclude/unexclude flow
|
||||||
|
- NO DB migrations
|
||||||
|
- NO new dependencies (no send2trash)
|
||||||
|
- NO changes to download flows
|
||||||
|
- NO recipe-JSON rewriting on model undo (hash-based refs re-resolve themselves)
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None - all implementation details resolved by exploration. Design decisions settled in conversation (B+C, no type-to-confirm).
|
||||||
|
|
||||||
|
## Approval gate
|
||||||
|
status: approved
|
||||||
|
<!-- Approach approved -> rerun scaffold without --draft-only, run Metis gap analysis, APPEND todo batches, fill TL;DR last, run structural self-check, then Phase 4 handoff. -->
|
||||||
|
|
||||||
|
## Review round state (ulw-plan-review-round-state-contract)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transition": "replace",
|
||||||
|
"phase": "review_round_initialized",
|
||||||
|
"applies_when": ["retry_after_plan_change"],
|
||||||
|
"atomic": true,
|
||||||
|
"review_required": true,
|
||||||
|
"plan_path": ".omo/plans/undo-delete-staging.md",
|
||||||
|
"plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc",
|
||||||
|
"review_round_id": "rr-undo-del-20260811-006",
|
||||||
|
"round_status": "active",
|
||||||
|
"pending-action": "review .omo/plans/undo-delete-staging.md",
|
||||||
|
"review": {
|
||||||
|
"momus": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null },
|
||||||
|
"independent": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review results + fix/retry ledger
|
||||||
|
|
||||||
|
### Round 1 (rr-undo-del-20260811-001, plan sha256 6c52bf99...)
|
||||||
|
- momus: APPROVE (non-blocking notes: todo1+7 duplicate DEFAULT_SETTINGS key -> fixed todo 7 to verify-only; "batch_ids" plural in todos 8/9 acceptance -> fixed; purge OSError note -> folded into todo 1 purge semantics)
|
||||||
|
- independent (oracle): CHANGES_REQUESTED
|
||||||
|
- BLOCKING S1: scanner walks would index .lm-pending-delete staged files as ghost entries -> fixed: todo 1 now mandates scanner walk exclusion at model_scanner.py:706/:867/:1404/_process_model_file + acceptance (o) scanner-visibility test
|
||||||
|
- BLOCKING S2: manifest lacks model_type, undo could restore into wrong cache/hash index -> fixed: manifest now carries model_type + todo 5 resolves per-type scanner via registrar pattern + acceptance (b) checkpoint-batch test
|
||||||
|
- S3 merged-batch expires_at re-anchor -> fixed: merge_batches re-anchors now+TTL in todo 1 + todo 3/4 assertions
|
||||||
|
- S4 manifest-less dir policy -> fixed: quarantine to <batch_id>.orphaned, never delete (todo 1 + acceptance g)
|
||||||
|
- S5 partial-undo retry semantics -> fixed: per-entry restored flag write-through + retry test (acceptance e)
|
||||||
|
- S6 purge locked-file failure semantics -> fixed: skip file, keep batch, never rmtree past errors (todo 1 + acceptance i)
|
||||||
|
- T8 undo-after-restart test -> fixed: todo 5 acceptance (f)
|
||||||
|
- T9 recipe undo -> re-delete test -> fixed: todo 5 acceptance (h)
|
||||||
|
- T7 rescan-stale-entry test -> fixed: todo 5 acceptance (g)
|
||||||
|
- Route registration pinned to shared routes class per mode (NOT per-model-type registrar which registers 3x) -> fixed: todo 5 now creates py/routes/pending_delete_routes.py registered once in lora_manager.py:170-172 + standalone.py:356-358 + duplicate-route test (e)
|
||||||
|
- Version-index staleness on single-delete undo -> fixed: todo 5 follows bulk cache-update pattern incl. rebuild_version_index (model_scanner.py:2324)
|
||||||
|
- Cancelled-bulk batch_id frontend handling -> fixed: todo 9 shows action toast on cancelled+staged-subset
|
||||||
|
- Single-instance assumption -> added to Scope OUT
|
||||||
|
- Occupied-refusal loss UX -> accepted-intent documented in success criteria + modal copy
|
||||||
|
|
||||||
|
### Round 2 (rr-undo-del-20260811-002, plan sha256 f3d52235...)
|
||||||
|
- momus: APPROVE (all 12 round-1 fixes verified present; zero dead references; non-blocking nits only)
|
||||||
|
- independent (oracle): CHANGES_REQUESTED
|
||||||
|
- BLOCK-1: merge_batches file-movement semantics unspecified (silent data-loss vector) -> fixed: todo 1 now specifies move-into-winner-dir + entry re-point + loser-dirs-removed-only-when-empty + abort-on-move-failure (all batches intact) + merge inside service lock + acceptance (k) file-survival assertions + acceptance (l) merge-failure abort test
|
||||||
|
- BLOCK-2: same-file parallel edits within waves (todo 5 vs 6 on lora_manager.py; todo 8 vs 9 on baseModelApi.js) -> fixed: waves/matrix now serialize 5->6 and 8->9 with explicit reasons; matrix updated
|
||||||
|
- Recommended: checkpoint_scanner.py:331 exclusion -> fixed (todo 1 + acceptance p); S5 pre-check skips restored:true entries -> fixed (todo 1); _tags_count restore on undo -> fixed (todo 5 + acceptance j); undo-blind flows documented (ModelVersionsTab + misc_handlers:2456) -> fixed (todo 8 note + Scope OUT); merge-failure no-merge fallback contract (batch_ids array) -> fixed (todos 3/4/9)
|
||||||
|
|
||||||
|
### Round 3 (rr-undo-del-20260811-003, plan sha256 8f2dfd46...)
|
||||||
|
- momus: APPROVE (all round-2 fixes verified present + spot-checked refs; no new contradictions)
|
||||||
|
- independent (oracle): CHANGES_REQUESTED
|
||||||
|
- BLOCKING A: merged batches never timer-purged after re-anchor (winner's original timer no-ops at old expiry; no fresh timer for re-anchored expiry; idle server -> merged batch lingers, violating "30s purge" success criterion; affects EVERY bulk delete) -> fixed: todo 1 merge_batches now ARMS A FRESH PURGE TIMER for the winner with re-anchored expiry + acceptance (q) fresh-timer test + purge_expired must enumerate ALL scanner types' roots (explicit in todo 1)
|
||||||
|
- BLOCKING B: dependency matrix contradicted same-file policy for todos 8/9<->11 (5 shared files) and 12<->11 -> fixed: todo 11 now "Blocked by: 8, 9 (same files...)"; todo 12 blocked by 11 (sync after 11); wave text updated (11, then 12 AFTER 11); "Can parallelize with" columns corrected
|
||||||
|
- BLOCKING C: frontend batch_ids sequential-undo fallback has NO test + merge->undo loser-restore + merge->purge assertions missing -> fixed: todo 9 acceptance now tests the batch_ids fallback path; todo 1 acceptance now has (k2)/(k3)
|
||||||
|
- Notes folded: sub-second toast-tail expiry race accepted; EXDEV fallback = NORMAL path for cross-volume bulks [annotated 2026-08: after the sibling-staging fix, EXDEV can only arise during cross-volume MERGES, never during single stage/undo renames]
|
||||||
|
|
||||||
|
### Round 4 (rr-undo-del-20260811-004, plan sha256 179e7ff7...)
|
||||||
|
- momus: APPROVE (round-3 fixes verified; one non-blocking nit: todo 11 inline "Blocked by: —" stale -> fixed to "8, 9")
|
||||||
|
- independent (oracle): CHANGES_REQUESTED
|
||||||
|
- BLOCKING GAP-1 (NEW, introduced by round-3 fix): todo 8 handleUndoDelete always-refresh/always-toast contract contradicted todo 9's sequential loop "exactly ONE final refresh" -> fixed: handleUndoDelete(batchId, refreshFn, {showToast, refresh}) suppression options; todo 9 loop uses suppressed calls + one final refresh/toast; acceptance extended (loop failure mid-way -> stop + error toast + no final refresh; 404 body discrimination expired vs occupied)
|
||||||
|
- BLOCKING GAP-2: no cross-type purge enumeration test -> fixed: todo 1 acceptance (r) purges expired batches across lora root + checkpoint root + recipe staging dir in one call
|
||||||
|
- Non-blocking folded: GAP-3 404-copy discrimination -> fixed in todo 8 (d); GAP-4 merge partial-failure rollback direction (move back + restore manifests, extended (l) asserts sequential constituent undo still restores everything) -> fixed in todo 1; GAP-5 post-restart timer-loss residual gap documented -> fixed in todo 6; GAP-6 usage_stats.py:424 walk added to exclusion mandate + todo 5 acceptance (k) embeddings undo test
|
||||||
|
|
||||||
|
### Round 5 (rr-undo-del-20260811-005, plan sha256 dfaa39ea...)
|
||||||
|
- momus: APPROVE (all round-4 fixes verified; no new contradictions)
|
||||||
|
- independent (oracle): CHANGES_REQUESTED
|
||||||
|
- BLOCK-1: lock-ordering deadlock ambiguity (asyncio.Lock not re-entrant: opportunistic purge_expired called while stage/undo hold the lock would deadlock on first use) -> fixed: todo 1 now has explicit LOCK HIERARCHY (lock acquired ONLY by stage/merge/undo/purge_batch; purge_expired is lock-free and must be called BEFORE lock acquisition); todo 6 (c) updated with the same rule + acceptance (u) lock-no-deadlock test
|
||||||
|
- BLOCK-2: purge edge semantics unspecified -> fixed: purge_batch treats missing staged files (partially-restored batches) as already-purged (FileNotFoundError silent no-op); sweep skips `.orphaned`-suffixed dirs (quarantine is terminal); acceptance (s) partially-restored purge + (t) quarantine-terminal tests
|
||||||
|
- Non-blocking folded: todo 2/3 test-file collision -> todo 3's bulk tests moved to tests/services/test_model_scanner.py; todo 9 (d) DuplicatesManager refreshFn stated explicitly (recipes loadRecipes / models resetAndReload); modal-copy + bulk-count trade-offs acknowledged in success criteria; acceptance (r) extended with embeddings root
|
||||||
|
|
||||||
|
### Round 6 (rr-undo-del-20260811-006, plan sha256 8cf7c9be...)
|
||||||
|
- momus: APPROVE (all round-5 fixes verified; no new contradictions; references verified)
|
||||||
|
- independent (oracle): APPROVE — no blocking issues; all round-5 items fixed with working, tested solutions; no new race/data-loss/consistency defects
|
||||||
|
- Deferred optional improvements (non-blocking, recorded for executor awareness; plan file left untouched to preserve the approved digest):
|
||||||
|
1. Tag-count asymmetry: single delete_model never decrements _tags_count (lifecycle 101-154), bulk does (scanner 2297-2303); undo re-increment is exact for bulk, over-counts for single until rescan (cosmetic, self-healing). Optional fix riding in todo 2: decrement tags in the single-delete path to mirror bulk.
|
||||||
|
2. Todo 5 factual nit: ModelCache.resort() already rebuilds the version index — explicit rebuild in undo is belt-and-braces, no action needed.
|
||||||
|
3. Todo 8 premise nit: ModelVersionsTab call ignores deleteModel's return entirely — nothing breaks, no adaptation needed.
|
||||||
|
4. Todo 3's pytest command includes test_model_lifecycle_service.py which todo 2 edits in the same wave — run that file's tests after todo 2 lands.
|
||||||
|
5. merge_batches with a missing/quarantined constituent id: any sane fallback (abort -> batch_ids, or skip missing) acceptable — files stay staged either way.
|
||||||
|
|
||||||
|
## Review lifecycle
|
||||||
|
- rounds: 6 (rr-undo-del-20260811-001..006); final round both lanes APPROVE
|
||||||
|
- final live-plan validation: sha256 = 8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc — MATCHES approved round-6 digest
|
||||||
|
- status: APPROVED — ready for execution handoff ($start-work undo-delete-staging)
|
||||||
File diff suppressed because one or more lines are too long
@@ -31,7 +31,7 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
|
|||||||
--cov-report=xml:coverage/backend/coverage.xml
|
--cov-report=xml:coverage/backend/coverage.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development (Standalone Web UI)
|
### Frontend Development (LoRA Manager Web UI)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -154,9 +154,9 @@ npm run test:coverage # Generate coverage report
|
|||||||
|
|
||||||
## Frontend UI Architecture
|
## Frontend UI Architecture
|
||||||
|
|
||||||
### 1. Standalone Web UI
|
### 1. LoRA Manager Web UI
|
||||||
- Location: `./static/` and `./templates/`
|
- Location: `./static/` and `./templates/`
|
||||||
- Tech: Vanilla JS + CSS, served by standalone server
|
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
|
||||||
- Tests via npm in root directory
|
- Tests via npm in root directory
|
||||||
|
|
||||||
### 2. ComfyUI Custom Node Widgets
|
### 2. ComfyUI Custom Node Widgets
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ These fields are present in all model metadata files.
|
|||||||
| `metadata_source` | string\|null | ❌ No | ✅ Yes | Last provider that supplied metadata (see below) |
|
| `metadata_source` | string\|null | ❌ No | ✅ Yes | Last provider that supplied metadata (see below) |
|
||||||
| `last_checked_at` | float | ❌ No (default: `0`) | ✅ Yes | Unix timestamp of last metadata check |
|
| `last_checked_at` | float | ❌ No (default: `0`) | ✅ Yes | Unix timestamp of last metadata check |
|
||||||
| `hash_status` | string | ❌ No (default: `"completed"`) | ✅ Yes | Hash calculation status: `"pending"`, `"calculating"`, `"completed"`, `"failed"` |
|
| `hash_status` | string | ❌ No (default: `"completed"`) | ✅ Yes | Hash calculation status: `"pending"`, `"calculating"`, `"completed"`, `"failed"` |
|
||||||
|
| `autov3` | string\|null | ❌ No | ✅ Yes | CivitAI AutoV3 hash (first 12 chars, lowercase hex) sourced from the safetensors embedded metadata (`sshs_model_hash` / `modelspec.hash_sha256`). **Absent** = not yet checked (may be backfilled later); **`null`** = checked but unavailable (header has no recognized hash); **12-char hex string** = value |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,6 +288,7 @@ These fields are automatically synchronized with the filesystem:
|
|||||||
- `preview_url` — Updated if preview file is moved/removed
|
- `preview_url` — Updated if preview file is moved/removed
|
||||||
- `sha256` — Updated during hash calculation (when `hash_status="pending"`)
|
- `sha256` — Updated during hash calculation (when `hash_status="pending"`)
|
||||||
- `hash_status` — Updated during hash calculation
|
- `hash_status` — Updated during hash calculation
|
||||||
|
- `autov3` — Set when metadata is first created (from safetensors header); may be backfilled later for entries where it is absent
|
||||||
- `last_checked_at` — Timestamp of scan
|
- `last_checked_at` — Timestamp of scan
|
||||||
- `metadata_source` — Set based on metadata provider
|
- `metadata_source` — Set based on metadata provider
|
||||||
|
|
||||||
@@ -345,6 +347,7 @@ These fields can be edited by users at any time through the Lora Manager UI or b
|
|||||||
| `metadata_source` | `null` |
|
| `metadata_source` | `null` |
|
||||||
| `last_checked_at` | `0` |
|
| `last_checked_at` | `0` |
|
||||||
| `hash_status` | `"completed"` |
|
| `hash_status` | `"completed"` |
|
||||||
|
| `autov3` | absent (not checked) or `null` (checked, no value) |
|
||||||
| `usage_tips` | `"{}"` (LoRA only) |
|
| `usage_tips` | `"{}"` (LoRA only) |
|
||||||
| `model_type` | `"checkpoint"` or `"embedding"` (not present in LoRA models) |
|
| `model_type` | `"checkpoint"` or `"embedding"` (not present in LoRA models) |
|
||||||
|
|
||||||
@@ -354,6 +357,7 @@ These fields can be edited by users at any time through the Lora Manager UI or b
|
|||||||
|
|
||||||
| Version | Date | Changes |
|
| Version | Date | Changes |
|
||||||
|---------|------|---------|
|
|---------|------|---------|
|
||||||
|
| 1.1 | 2026-08 | Added `autov3` field (CivitAI AutoV3 hash with three-state semantics) |
|
||||||
| 1.0 | 2026-03 | Initial schema documentation |
|
| 1.0 | 2026-03 | Initial schema documentation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||||
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "Rezepte lokalen Modellen neu zuordnen",
|
||||||
|
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
||||||
|
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||||
|
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
||||||
|
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
|
||||||
|
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
||||||
|
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
||||||
|
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Ausgeschlossene Modelle verwalten"
|
"label": "Ausgeschlossene Modelle verwalten"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (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.",
|
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
|
||||||
|
"recipesLayout": "Rezepte-Layout",
|
||||||
|
"recipesLayoutHelp": "Wählen Sie, wie Rezeptkarten angeordnet werden: ein einheitliches Raster oder ein Masonry-Layout (Pinterest-Stil), das das Seitenverhältnis jedes Bildes beibehält.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "Raster",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
|
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
|
||||||
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
|
||||||
"cardInfoDisplay": "Karten-Info-Anzeige",
|
"cardInfoDisplay": "Karten-Info-Anzeige",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "Alle Syntax kopieren",
|
"copyAll": "Alle Syntax kopieren",
|
||||||
"refreshAll": "Alle Metadaten aktualisieren",
|
"refreshAll": "Alle Metadaten aktualisieren",
|
||||||
"repairMetadata": "Metadaten der Auswahl reparieren",
|
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||||
|
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
|
||||||
"reimportMetadata": "Aus Quelle neu importieren",
|
"reimportMetadata": "Aus Quelle neu importieren",
|
||||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||||
"moveAll": "Alle in Ordner verschieben",
|
"moveAll": "Alle in Ordner verschieben",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "Inhaltsbewertung festlegen",
|
"setContentRating": "Inhaltsbewertung festlegen",
|
||||||
"moveToFolder": "In Ordner verschieben",
|
"moveToFolder": "In Ordner verschieben",
|
||||||
"repairMetadata": "Metadaten reparieren",
|
"repairMetadata": "Metadaten reparieren",
|
||||||
|
"rematchMetadata": "Mit lokalen Modellen abgleichen",
|
||||||
"reimportMetadata": "Aus Quelle neu importieren",
|
"reimportMetadata": "Aus Quelle neu importieren",
|
||||||
"excludeModel": "Modell ausschließen",
|
"excludeModel": "Modell ausschließen",
|
||||||
"restoreModel": "Modell wiederherstellen",
|
"restoreModel": "Modell wiederherstellen",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} Duplikat-Gruppen gefunden",
|
"found": "{count} Duplikat-Gruppen gefunden",
|
||||||
|
"noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden",
|
||||||
"keepLatest": "Neueste Versionen behalten",
|
"keepLatest": "Neueste Versionen behalten",
|
||||||
"deleteSelected": "Ausgewählte löschen"
|
"deleteSelected": "Ausgewählte löschen",
|
||||||
|
"includePromptLabel": "Prompt beim Abgleich berücksichtigen",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "Abgeglichen nach: LoRA-Kombination",
|
||||||
|
"loraComboAndPrompt": "Abgeglichen nach: LoRA-Kombination + Prompt",
|
||||||
|
"hintLoraCombo": "Rezepte mit denselben LoRAs bei identischen Stärken werden gruppiert.",
|
||||||
|
"hintPromptIncluded": "Rezepte werden nur gruppiert, wenn sie dieselben LoRAs bei identischen Stärken UND denselben Prompt verwenden."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "Modell löschen",
|
"title": "Modell löschen",
|
||||||
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?"
|
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "Diese Aktion kann 30 Sekunden lang rückgängig gemacht werden."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "Modell ausschließen",
|
"title": "Modell ausschließen",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "Fehler"
|
"columnError": "Fehler"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Zusammenfassung des Batch-Downloads",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Erfolgreich",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Fehlgeschlagen",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Gesamt",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Alle {count} Modelle erfolgreich heruntergeladen",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Abgeschlossen, aber mit Fehlern",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Download fehlgeschlagen",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Fehlgeschlagene Elemente ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Modellname",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Fehler",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Schließen",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Bericht kopieren",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Fehlgeschlagene erneut versuchen ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||||
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||||
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
|
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||||
|
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
||||||
|
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
|
||||||
|
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
||||||
|
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
||||||
|
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||||
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
||||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
|
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
|
||||||
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
|
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
|
||||||
"presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
|
"presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
|
||||||
"maxPresetsReached": "Maximal {max} Voreinstellungen erlaubt. Löschen Sie eine, um weitere hinzuzufügen.",
|
|
||||||
"presetNotFound": "Voreinstellung nicht gefunden",
|
"presetNotFound": "Voreinstellung nicht gefunden",
|
||||||
"invalidPreset": "Ungültige Voreinstellungsdaten",
|
"invalidPreset": "Ungültige Voreinstellungsdaten",
|
||||||
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
|
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
||||||
"copyFailed": "Kopieren fehlgeschlagen"
|
"copyFailed": "Kopieren fehlgeschlagen"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "Fehler beim Laden der Elemente",
|
"loadFailed": "Fehler beim Laden der Elemente",
|
||||||
"loadMoreFailed": "Fehler beim Laden weiterer Elemente",
|
"loadMoreFailed": "Fehler beim Laden weiterer Elemente",
|
||||||
|
|||||||
+47
-3
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||||
"error": "Recipe repair failed: {message}"
|
"error": "Recipe repair failed: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "Rematch recipes to local models",
|
||||||
|
"loading": "Rematching recipes to local models...",
|
||||||
|
"success": "Matched {entries} entries across {recipes} recipes",
|
||||||
|
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
||||||
|
"allFailed": "Rematch failed for {failures} of {total} recipes",
|
||||||
|
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
|
||||||
|
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
||||||
|
"error": "Recipe rematch failed: {message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Manage Excluded Models"
|
"label": "Manage Excluded Models"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
|
||||||
|
"recipesLayout": "Recipes Layout",
|
||||||
|
"recipesLayoutHelp": "Choose how recipe cards are arranged: a uniform grid or a masonry (Pinterest-style) layout that preserves each image's aspect ratio.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "Grid",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "Show Folder Sidebar",
|
"showFolderSidebar": "Show Folder Sidebar",
|
||||||
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
|
||||||
"cardInfoDisplay": "Card Info Display",
|
"cardInfoDisplay": "Card Info Display",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "Copy Selected Syntax",
|
"copyAll": "Copy Selected Syntax",
|
||||||
"refreshAll": "Refresh Selected Metadata",
|
"refreshAll": "Refresh Selected Metadata",
|
||||||
"repairMetadata": "Repair Metadata for Selected",
|
"repairMetadata": "Repair Metadata for Selected",
|
||||||
|
"rematchMetadata": "Rematch Selected to Local Models",
|
||||||
"reimportMetadata": "Re-import from Source",
|
"reimportMetadata": "Re-import from Source",
|
||||||
"checkUpdates": "Check Updates for Selected",
|
"checkUpdates": "Check Updates for Selected",
|
||||||
"moveAll": "Move Selected to Folder",
|
"moveAll": "Move Selected to Folder",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "Set Content Rating",
|
"setContentRating": "Set Content Rating",
|
||||||
"moveToFolder": "Move to Folder",
|
"moveToFolder": "Move to Folder",
|
||||||
"repairMetadata": "Repair metadata",
|
"repairMetadata": "Repair metadata",
|
||||||
|
"rematchMetadata": "Rematch to local models",
|
||||||
"reimportMetadata": "Re-import from Source",
|
"reimportMetadata": "Re-import from Source",
|
||||||
"excludeModel": "Exclude Model",
|
"excludeModel": "Exclude Model",
|
||||||
"restoreModel": "Restore Model",
|
"restoreModel": "Restore Model",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Found {count} duplicate groups",
|
"found": "Found {count} duplicate groups",
|
||||||
|
"noGroups": "No duplicate groups found with the current matching basis",
|
||||||
"keepLatest": "Keep Latest Versions",
|
"keepLatest": "Keep Latest Versions",
|
||||||
"deleteSelected": "Delete Selected"
|
"deleteSelected": "Delete Selected",
|
||||||
|
"includePromptLabel": "Include prompt in matching",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "Matched by: LoRA combination",
|
||||||
|
"loraComboAndPrompt": "Matched by: LoRA combination + prompt",
|
||||||
|
"hintLoraCombo": "Recipes with the same LoRAs at identical strengths are grouped.",
|
||||||
|
"hintPromptIncluded": "Recipes are grouped only when they use the same LoRAs at identical strengths AND have the same prompt."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "Frees {size}",
|
||||||
"title": "Delete Model",
|
"title": "Delete Model",
|
||||||
"message": "Are you sure you want to delete this model and all associated files?"
|
"message": "Are you sure you want to delete this model and all associated files?",
|
||||||
|
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "This action can be undone for 30 seconds."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "Exclude Model",
|
"title": "Exclude Model",
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||||
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||||
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
||||||
|
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
|
||||||
|
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
||||||
|
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
|
||||||
|
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
|
||||||
|
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
||||||
|
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||||
"reimporting": "Re-importing recipe from source...",
|
"reimporting": "Re-importing recipe from source...",
|
||||||
"reimportSuccess": "Recipe re-imported successfully",
|
"reimportSuccess": "Recipe re-imported successfully",
|
||||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "Preset name must be {max} characters or less",
|
"presetNameTooLong": "Preset name must be {max} characters or less",
|
||||||
"presetNameInvalidChars": "Preset name contains invalid characters",
|
"presetNameInvalidChars": "Preset name contains invalid characters",
|
||||||
"presetNameExists": "A preset with this name already exists",
|
"presetNameExists": "A preset with this name already exists",
|
||||||
"maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
|
|
||||||
"presetNotFound": "Preset not found",
|
"presetNotFound": "Preset not found",
|
||||||
"invalidPreset": "Invalid preset data",
|
"invalidPreset": "Invalid preset data",
|
||||||
"deletePresetFailed": "Failed to delete preset",
|
"deletePresetFailed": "Failed to delete preset",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "Failed to update trigger words",
|
"updateFailed": "Failed to update trigger words",
|
||||||
"copyFailed": "Copy failed"
|
"copyFailed": "Copy failed"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "Undo",
|
||||||
|
"deleted": "Deleted {name}",
|
||||||
|
"deletedBulk": "Deleted {count} item(s)",
|
||||||
|
"expired": "Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "Undo failed: {error}",
|
||||||
|
"restored": "Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "Failed to load items",
|
"loadFailed": "Failed to load items",
|
||||||
"loadMoreFailed": "Failed to load more items",
|
"loadMoreFailed": "Failed to load more items",
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||||
"error": "Error al reparar recetas: {message}"
|
"error": "Error al reparar recetas: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "Reasociar recetas con modelos locales",
|
||||||
|
"loading": "Reasociando recetas con modelos locales...",
|
||||||
|
"success": "{entries} entradas asociadas en {recipes} recetas",
|
||||||
|
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
||||||
|
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
|
||||||
|
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
||||||
|
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
||||||
|
"error": "Falló la reasociación de recetas: {message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Gestionar modelos excluidos"
|
"label": "Gestionar modelos excluidos"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
|
||||||
|
"recipesLayout": "Diseño de recetas",
|
||||||
|
"recipesLayoutHelp": "Elige cómo se organizan las tarjetas de recetas: una cuadrícula uniforme o un diseño masonry (estilo Pinterest) que conserva la proporción de aspecto de cada imagen.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "Cuadrícula",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "Mostrar barra lateral de carpetas",
|
"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.",
|
"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",
|
"cardInfoDisplay": "Visualización de información de tarjeta",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "Copiar toda la sintaxis",
|
"copyAll": "Copiar toda la sintaxis",
|
||||||
"refreshAll": "Actualizar todos los metadatos",
|
"refreshAll": "Actualizar todos los metadatos",
|
||||||
"repairMetadata": "Reparar metadatos de la selección",
|
"repairMetadata": "Reparar metadatos de la selección",
|
||||||
|
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
|
||||||
"reimportMetadata": "Reimportar desde origen",
|
"reimportMetadata": "Reimportar desde origen",
|
||||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||||
"moveAll": "Mover todos a carpeta",
|
"moveAll": "Mover todos a carpeta",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "Establecer clasificación de contenido",
|
"setContentRating": "Establecer clasificación de contenido",
|
||||||
"moveToFolder": "Mover a carpeta",
|
"moveToFolder": "Mover a carpeta",
|
||||||
"repairMetadata": "Reparar metadatos",
|
"repairMetadata": "Reparar metadatos",
|
||||||
|
"rematchMetadata": "Reasociar con modelos locales",
|
||||||
"reimportMetadata": "Reimportar desde origen",
|
"reimportMetadata": "Reimportar desde origen",
|
||||||
"excludeModel": "Excluir modelo",
|
"excludeModel": "Excluir modelo",
|
||||||
"restoreModel": "Restaurar modelo",
|
"restoreModel": "Restaurar modelo",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Se encontraron {count} grupos de duplicados",
|
"found": "Se encontraron {count} grupos de duplicados",
|
||||||
|
"noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual",
|
||||||
"keepLatest": "Mantener versiones más recientes",
|
"keepLatest": "Mantener versiones más recientes",
|
||||||
"deleteSelected": "Eliminar seleccionados"
|
"deleteSelected": "Eliminar seleccionados",
|
||||||
|
"includePromptLabel": "Incluir prompt en la coincidencia",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "Coincidencia por: combinación de LoRA",
|
||||||
|
"loraComboAndPrompt": "Coincidencia por: combinación de LoRA + prompt",
|
||||||
|
"hintLoraCombo": "Se agrupan las recetas con los mismos LoRAs y las mismas intensidades.",
|
||||||
|
"hintPromptIncluded": "Las recetas solo se agrupan cuando usan los mismos LoRAs con intensidades idénticas Y tienen el mismo prompt."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "Eliminar modelo",
|
"title": "Eliminar modelo",
|
||||||
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?"
|
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "Esta acción se puede deshacer durante 30 segundos."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "Excluir modelo",
|
"title": "Excluir modelo",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "Error"
|
"columnError": "Error"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Resumen de descarga por lotes",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Correctos",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Fallidos",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Total",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Todos los {count} modelos se descargaron correctamente",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Completado con errores",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Descarga fallida",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Elementos fallidos ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Nombre del modelo",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Error",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Cerrar",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Copiar informe",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Reintentar fallidos ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||||
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||||
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
||||||
|
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
|
||||||
|
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
||||||
|
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
|
||||||
|
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
||||||
|
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
||||||
|
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||||
"reimporting": "Reimportando receta desde origen...",
|
"reimporting": "Reimportando receta desde origen...",
|
||||||
"reimportSuccess": "Receta reimportada exitosamente",
|
"reimportSuccess": "Receta reimportada exitosamente",
|
||||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
|
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
|
||||||
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
|
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
|
||||||
"presetNameExists": "Ya existe un preajuste con este nombre",
|
"presetNameExists": "Ya existe un preajuste con este nombre",
|
||||||
"maxPresetsReached": "Máximo {max} preajustes permitidos. Elimine uno para agregar más.",
|
|
||||||
"presetNotFound": "Preajuste no encontrado",
|
"presetNotFound": "Preajuste no encontrado",
|
||||||
"invalidPreset": "Datos de preajuste inválidos",
|
"invalidPreset": "Datos de preajuste inválidos",
|
||||||
"deletePresetFailed": "Error al eliminar el preajuste",
|
"deletePresetFailed": "Error al eliminar el preajuste",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "Error al actualizar palabras clave",
|
"updateFailed": "Error al actualizar palabras clave",
|
||||||
"copyFailed": "Error al copiar"
|
"copyFailed": "Error al copiar"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "Error al cargar elementos",
|
"loadFailed": "Error al cargar elementos",
|
||||||
"loadMoreFailed": "Error al cargar más elementos",
|
"loadMoreFailed": "Error al cargar más elementos",
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||||
"error": "Échec de la réparation des recettes : {message}"
|
"error": "Échec de la réparation des recettes : {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "Réassocier les recettes aux modèles locaux",
|
||||||
|
"loading": "Réassociation des recettes aux modèles locaux...",
|
||||||
|
"success": "{entries} entrées associées dans {recipes} recettes",
|
||||||
|
"successErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
|
||||||
|
"allFailed": "Échec de la réassociation de {failures} recettes sur {total}",
|
||||||
|
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes",
|
||||||
|
"cancelled": "Réassociation annulée. {recipes} recettes mises à jour ({entries} entrées)",
|
||||||
|
"error": "Échec de la réassociation des recettes : {message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Gérer les modèles exclus"
|
"label": "Gérer les modèles exclus"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (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.",
|
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
|
||||||
|
"recipesLayout": "Disposition des recettes",
|
||||||
|
"recipesLayoutHelp": "Choisissez comment les cartes de recettes sont organisées : une grille uniforme ou une disposition masonry (style Pinterest) qui préserve le rapport d'aspect de chaque image.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "Grille",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "Afficher la barre latérale des dossiers",
|
"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.",
|
"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",
|
"cardInfoDisplay": "Affichage des informations de carte",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "Copier toute la syntaxe",
|
"copyAll": "Copier toute la syntaxe",
|
||||||
"refreshAll": "Actualiser toutes les métadonnées",
|
"refreshAll": "Actualiser toutes les métadonnées",
|
||||||
"repairMetadata": "Réparer les métadonnées de la sélection",
|
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||||
|
"rematchMetadata": "Réassocier la sélection aux modèles locaux",
|
||||||
"reimportMetadata": "Ré-importer depuis la source",
|
"reimportMetadata": "Ré-importer depuis la source",
|
||||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||||
"moveAll": "Déplacer tout vers un dossier",
|
"moveAll": "Déplacer tout vers un dossier",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "Définir la classification du contenu",
|
"setContentRating": "Définir la classification du contenu",
|
||||||
"moveToFolder": "Déplacer vers un dossier",
|
"moveToFolder": "Déplacer vers un dossier",
|
||||||
"repairMetadata": "Réparer les métadonnées",
|
"repairMetadata": "Réparer les métadonnées",
|
||||||
|
"rematchMetadata": "Réassocier aux modèles locaux",
|
||||||
"reimportMetadata": "Ré-importer depuis la source",
|
"reimportMetadata": "Ré-importer depuis la source",
|
||||||
"excludeModel": "Exclure le modèle",
|
"excludeModel": "Exclure le modèle",
|
||||||
"restoreModel": "Restaurer le modèle",
|
"restoreModel": "Restaurer le modèle",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Trouvé {count} groupes de doublons",
|
"found": "Trouvé {count} groupes de doublons",
|
||||||
|
"noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel",
|
||||||
"keepLatest": "Garder les dernières versions",
|
"keepLatest": "Garder les dernières versions",
|
||||||
"deleteSelected": "Supprimer la sélection"
|
"deleteSelected": "Supprimer la sélection",
|
||||||
|
"includePromptLabel": "Inclure le prompt dans la correspondance",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "Correspondance : combinaison de LoRA",
|
||||||
|
"loraComboAndPrompt": "Correspondance : combinaison de LoRA + prompt",
|
||||||
|
"hintLoraCombo": "Les recettes avec les mêmes LoRAs et des forces identiques sont regroupées.",
|
||||||
|
"hintPromptIncluded": "Les recettes ne sont regroupées que si elles utilisent les mêmes LoRAs avec des forces identiques ET ont le même prompt."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "Supprimer le modèle",
|
"title": "Supprimer le modèle",
|
||||||
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?"
|
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "Cette action peut être annulée pendant 30 secondes."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "Exclure le modèle",
|
"title": "Exclure le modèle",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "Erreur"
|
"columnError": "Erreur"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Résumé du téléchargement groupé",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Réussis",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Échoués",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Total",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Les {count} modèles ont été téléchargés avec succès",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Terminé avec des erreurs",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Échec du téléchargement",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Éléments échoués ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Nom du modèle",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Erreur",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Fermer",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Copier le rapport",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Réessayer les échecs ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
||||||
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
|
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
|
||||||
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
|
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
|
||||||
|
"rematchComplete": "{entries} entrées associées dans {recipes} recettes",
|
||||||
|
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
|
||||||
|
"rematchAllFailed": "Échec de la réassociation de {failures} recettes sélectionnées sur {total}",
|
||||||
|
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes",
|
||||||
|
"rematchSkipped": "Aucune des {total} recettes sélectionnées ne nécessite de réassociation",
|
||||||
|
"rematchFailed": "Échec de la réassociation des recettes sélectionnées : {message}",
|
||||||
"reimporting": "Ré-import de la recette depuis la source...",
|
"reimporting": "Ré-import de la recette depuis la source...",
|
||||||
"reimportSuccess": "Recette ré-importée avec succès",
|
"reimportSuccess": "Recette ré-importée avec succès",
|
||||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
|
"presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
|
||||||
"presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
|
"presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
|
||||||
"presetNameExists": "Un préréglage avec ce nom existe déjà",
|
"presetNameExists": "Un préréglage avec ce nom existe déjà",
|
||||||
"maxPresetsReached": "Maximum {max} préréglages autorisés. Supprimez-en un pour en ajouter plus.",
|
|
||||||
"presetNotFound": "Préréglage non trouvé",
|
"presetNotFound": "Préréglage non trouvé",
|
||||||
"invalidPreset": "Données de préréglage invalides",
|
"invalidPreset": "Données de préréglage invalides",
|
||||||
"deletePresetFailed": "Échec de la suppression du préréglage",
|
"deletePresetFailed": "Échec de la suppression du préréglage",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "Échec de la mise à jour des mots-clés",
|
"updateFailed": "Échec de la mise à jour des mots-clés",
|
||||||
"copyFailed": "Échec de la copie"
|
"copyFailed": "Échec de la copie"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "Échec du chargement des éléments",
|
"loadFailed": "Échec du chargement des éléments",
|
||||||
"loadMoreFailed": "Échec du chargement de plus d'éléments",
|
"loadMoreFailed": "Échec du chargement de plus d'éléments",
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||||
"error": "תיקון המתכונים נכשל: {message}"
|
"error": "תיקון המתכונים נכשל: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||||
|
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||||
|
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||||
|
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||||
|
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
||||||
|
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||||
|
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||||
|
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "ניהול מודלים מוחרגים"
|
"label": "ניהול מודלים מוחרגים"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
|
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
|
||||||
|
"recipesLayout": "פריסת מתכונים",
|
||||||
|
"recipesLayoutHelp": "בחר כיצד יסודרו כרטיסי המתכונים: רשת אחידה או פריסת Masonry (בסגנון Pinterest) השומרת על יחס הגובה-רוחב של כל תמונה.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "רשת",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "הצג סרגל צד תיקיות",
|
"showFolderSidebar": "הצג סרגל צד תיקיות",
|
||||||
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
|
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
|
||||||
"cardInfoDisplay": "תצוגת מידע בכרטיס",
|
"cardInfoDisplay": "תצוגת מידע בכרטיס",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "העתק את כל התחבירים",
|
"copyAll": "העתק את כל התחבירים",
|
||||||
"refreshAll": "רענן את כל המטא-דאטה",
|
"refreshAll": "רענן את כל המטא-דאטה",
|
||||||
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||||
|
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||||
"moveAll": "העבר הכל לתיקייה",
|
"moveAll": "העבר הכל לתיקייה",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "הגדר דירוג תוכן",
|
"setContentRating": "הגדר דירוג תוכן",
|
||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
"repairMetadata": "תיקון מטא-דאטה",
|
"repairMetadata": "תיקון מטא-דאטה",
|
||||||
|
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
"restoreModel": "שחזור מודל",
|
"restoreModel": "שחזור מודל",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "נמצאו {count} קבוצות כפולות",
|
"found": "נמצאו {count} קבוצות כפולות",
|
||||||
|
"noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי",
|
||||||
"keepLatest": "שמור גרסאות אחרונות",
|
"keepLatest": "שמור גרסאות אחרונות",
|
||||||
"deleteSelected": "מחק נבחרים"
|
"deleteSelected": "מחק נבחרים",
|
||||||
|
"includePromptLabel": "כלול הנחיה בהתאמה",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "התאמה לפי: שילוב LoRA",
|
||||||
|
"loraComboAndPrompt": "התאמה לפי: שילוב LoRA + הנחיה",
|
||||||
|
"hintLoraCombo": "מתכונים עם אותם LoRAs בעוצמות זהות מקובצים יחד.",
|
||||||
|
"hintPromptIncluded": "מתכונים מקובצים רק כאשר הם משתמשים באותם LoRAs בעוצמות זהות ויש להם אותה הנחיה."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "מחק מודל",
|
"title": "מחק מודל",
|
||||||
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?"
|
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "ניתן לבטל פעולה זו תוך 30 שניות."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "החרג מודל",
|
"title": "החרג מודל",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "שגיאה"
|
"columnError": "שגיאה"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "סיכום הורדה בכמות",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "הצליחו",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "נכשלו",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "סה\"כ",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "כל {count} הדגמים הורדו בהצלחה",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "הושלם עם שגיאות",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "ההורדה נכשלה",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "פריטים שנכשלו ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "שם הדגם",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "שגיאה",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "סגור",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "העתק דוח",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "נסה שוב ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||||
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||||
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||||
|
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||||
|
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||||
|
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
||||||
|
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||||
|
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||||
|
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||||
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
||||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
|
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
|
||||||
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
|
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
|
||||||
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
|
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
|
||||||
"maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
|
|
||||||
"presetNotFound": "קביעה מראש לא נמצאה",
|
"presetNotFound": "קביעה מראש לא נמצאה",
|
||||||
"invalidPreset": "נתוני קביעה מראש לא חוקיים",
|
"invalidPreset": "נתוני קביעה מראש לא חוקיים",
|
||||||
"deletePresetFailed": "מחיקת קביעה מראש נכשלה",
|
"deletePresetFailed": "מחיקת קביעה מראש נכשלה",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "עדכון מילות הטריגר נכשל",
|
"updateFailed": "עדכון מילות הטריגר נכשל",
|
||||||
"copyFailed": "ההעתקה נכשלה"
|
"copyFailed": "ההעתקה נכשלה"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "טעינת הפריטים נכשלה",
|
"loadFailed": "טעינת הפריטים נכשלה",
|
||||||
"loadMoreFailed": "טעינת פריטים נוספים נכשלה",
|
"loadMoreFailed": "טעינת פריטים נוספים נכשלה",
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||||
"error": "レシピの修復に失敗しました: {message}"
|
"error": "レシピの修復に失敗しました: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "レシピをローカルモデルに再マッチング",
|
||||||
|
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||||
|
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||||
|
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||||
|
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||||
|
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||||
|
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||||
|
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "除外モデルを管理"
|
"label": "除外モデルを管理"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
|
||||||
|
"recipesLayout": "レシピのレイアウト",
|
||||||
|
"recipesLayoutHelp": "レシピカードの配置方法を選択:均一なグリッド、または各画像のアスペクト比を保持するメイソンリー(Pinterest スタイル)レイアウト。",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "グリッド",
|
||||||
|
"masonry": "メイソンリー"
|
||||||
|
},
|
||||||
"showFolderSidebar": "フォルダサイドバーを表示",
|
"showFolderSidebar": "フォルダサイドバーを表示",
|
||||||
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
|
||||||
"cardInfoDisplay": "カード情報表示",
|
"cardInfoDisplay": "カード情報表示",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||||
|
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"checkUpdates": "選択項目の更新を確認",
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
"moveAll": "すべてをフォルダに移動",
|
"moveAll": "すべてをフォルダに移動",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "コンテンツレーティングを設定",
|
"setContentRating": "コンテンツレーティングを設定",
|
||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
"repairMetadata": "メタデータを修復",
|
"repairMetadata": "メタデータを修復",
|
||||||
|
"rematchMetadata": "ローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
"restoreModel": "モデルを復元",
|
"restoreModel": "モデルを復元",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} 個の重複グループが見つかりました",
|
"found": "{count} 個の重複グループが見つかりました",
|
||||||
|
"noGroups": "現在の一致基準では重複グループが見つかりませんでした",
|
||||||
"keepLatest": "最新バージョンを保持",
|
"keepLatest": "最新バージョンを保持",
|
||||||
"deleteSelected": "選択したものを削除"
|
"deleteSelected": "選択したものを削除",
|
||||||
|
"includePromptLabel": "一致判定にプロンプトを含める",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "一致基準: LoRA の組み合わせ",
|
||||||
|
"loraComboAndPrompt": "一致基準: LoRA の組み合わせ + プロンプト",
|
||||||
|
"hintLoraCombo": "同じ LoRA を同じ強度で使用するレシピがグループ化されます。",
|
||||||
|
"hintPromptIncluded": "レシピは、同じ LoRA を同じ強度で使用し、かつプロンプトが同じ場合にのみグループ化されます。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "モデルを削除",
|
"title": "モデルを削除",
|
||||||
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?"
|
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "この操作は30秒以内であれば元に戻せます。"
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "モデルを除外",
|
"title": "モデルを除外",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "エラー"
|
"columnError": "エラー"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "バッチダウンロードの概要",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "成功",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "失敗",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "合計",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "{count} 個のモデルがすべて正常にダウンロードされました",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "エラーありで完了",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "ダウンロードに失敗しました",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "失敗した項目({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "モデル名",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "エラー",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "閉じる",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "レポートをコピー",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "失敗した項目を再試行({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||||
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||||
|
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||||
|
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||||
|
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||||
|
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||||
|
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||||
|
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||||
"reimporting": "ソースからレシピを再インポート中...",
|
"reimporting": "ソースからレシピを再インポート中...",
|
||||||
"reimportSuccess": "レシピの再インポートが完了しました",
|
"reimportSuccess": "レシピの再インポートが完了しました",
|
||||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "プリセット名は{max}文字以内にしてください",
|
"presetNameTooLong": "プリセット名は{max}文字以内にしてください",
|
||||||
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
|
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
|
||||||
"presetNameExists": "同じ名前のプリセットが既に存在します",
|
"presetNameExists": "同じ名前のプリセットが既に存在します",
|
||||||
"maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
|
|
||||||
"presetNotFound": "プリセットが見つかりません",
|
"presetNotFound": "プリセットが見つかりません",
|
||||||
"invalidPreset": "無効なプリセットデータです",
|
"invalidPreset": "無効なプリセットデータです",
|
||||||
"deletePresetFailed": "プリセットの削除に失敗しました",
|
"deletePresetFailed": "プリセットの削除に失敗しました",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "トリガーワードの更新に失敗しました",
|
"updateFailed": "トリガーワードの更新に失敗しました",
|
||||||
"copyFailed": "コピーに失敗しました"
|
"copyFailed": "コピーに失敗しました"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "アイテムの読み込みに失敗しました",
|
"loadFailed": "アイテムの読み込みに失敗しました",
|
||||||
"loadMoreFailed": "追加アイテムの読み込みに失敗しました",
|
"loadMoreFailed": "追加アイテムの読み込みに失敗しました",
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||||
"error": "레시피 복구 실패: {message}"
|
"error": "레시피 복구 실패: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||||
|
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||||
|
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||||
|
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||||
|
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
||||||
|
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||||
|
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||||
|
"error": "레시피 재매칭 실패: {message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "제외된 모델 관리"
|
"label": "제외된 모델 관리"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
|
||||||
|
"recipesLayout": "레시피 레이아웃",
|
||||||
|
"recipesLayoutHelp": "레시피 카드의 배열 방식을 선택하세요: 균일한 그리드 또는 각 이미지의 종횡비를 유지하는 메이슨리(Pinterest 스타일) 레이아웃.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "그리드",
|
||||||
|
"masonry": "메이슨리"
|
||||||
|
},
|
||||||
"showFolderSidebar": "폴더 사이드바 표시",
|
"showFolderSidebar": "폴더 사이드바 표시",
|
||||||
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
|
||||||
"cardInfoDisplay": "카드 정보 표시",
|
"cardInfoDisplay": "카드 정보 표시",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||||
|
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"checkUpdates": "선택 항목 업데이트 확인",
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
"moveAll": "모두 폴더로 이동",
|
"moveAll": "모두 폴더로 이동",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "콘텐츠 등급 설정",
|
"setContentRating": "콘텐츠 등급 설정",
|
||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
"repairMetadata": "메타데이터 복구",
|
"repairMetadata": "메타데이터 복구",
|
||||||
|
"rematchMetadata": "로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
"restoreModel": "모델 복원",
|
"restoreModel": "모델 복원",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count}개의 중복 그룹 발견",
|
"found": "{count}개의 중복 그룹 발견",
|
||||||
|
"noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다",
|
||||||
"keepLatest": "최신 버전 유지",
|
"keepLatest": "최신 버전 유지",
|
||||||
"deleteSelected": "선택된 항목 삭제"
|
"deleteSelected": "선택된 항목 삭제",
|
||||||
|
"includePromptLabel": "일치 항목에 프롬프트 포함",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "일치 기준: LoRA 조합",
|
||||||
|
"loraComboAndPrompt": "일치 기준: LoRA 조합 + 프롬프트",
|
||||||
|
"hintLoraCombo": "동일한 LoRA를 동일한 강도로 사용하는 레시피가 그룹화됩니다.",
|
||||||
|
"hintPromptIncluded": "동일한 LoRA를 동일한 강도로 사용하고 프롬프트도 동일한 경우에만 레시피가 그룹화됩니다."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "모델 삭제",
|
"title": "모델 삭제",
|
||||||
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?"
|
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "이 작업은 30초 이내에 실행 취소할 수 있습니다."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "모델 제외",
|
"title": "모델 제외",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "오류"
|
"columnError": "오류"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "일괄 다운로드 요약",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "성공",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "실패",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "전체",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "{count}개 모델이 모두 성공적으로 다운로드되었습니다",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "오류와 함께 완료됨",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "다운로드 실패",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "실패한 항목 ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "모델 이름",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "오류",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "닫기",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "보고서 복사",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "실패 항목 재시도 ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||||
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||||
|
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||||
|
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||||
|
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
||||||
|
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||||
|
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||||
|
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||||
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
||||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
|
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
|
||||||
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
|
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
|
||||||
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
|
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
|
||||||
"maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
|
|
||||||
"presetNotFound": "프리셋을 찾을 수 없습니다",
|
"presetNotFound": "프리셋을 찾을 수 없습니다",
|
||||||
"invalidPreset": "잘못된 프리셋 데이터입니다",
|
"invalidPreset": "잘못된 프리셋 데이터입니다",
|
||||||
"deletePresetFailed": "프리셋 삭제에 실패했습니다",
|
"deletePresetFailed": "프리셋 삭제에 실패했습니다",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
||||||
"copyFailed": "복사 실패"
|
"copyFailed": "복사 실패"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "항목 로딩 실패",
|
"loadFailed": "항목 로딩 실패",
|
||||||
"loadMoreFailed": "더 많은 항목 로딩 실패",
|
"loadMoreFailed": "더 많은 항목 로딩 실패",
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||||
"error": "Ошибка восстановления рецептов: {message}"
|
"error": "Ошибка восстановления рецептов: {message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||||
|
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||||
|
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||||
|
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||||
|
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
||||||
|
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||||
|
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||||
|
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "Управление исключёнными моделями"
|
"label": "Управление исключёнными моделями"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
"compact": "7 (1080p), 8 (2K), 10 (4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
|
||||||
|
"recipesLayout": "Макет рецептов",
|
||||||
|
"recipesLayoutHelp": "Выберите, как располагаются карточки рецептов: единая сетка или masonry-макет (в стиле Pinterest), сохраняющий пропорции каждого изображения.",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "Сетка",
|
||||||
|
"masonry": "Masonry"
|
||||||
|
},
|
||||||
"showFolderSidebar": "Показывать боковую панель папок",
|
"showFolderSidebar": "Показывать боковую панель папок",
|
||||||
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
|
||||||
"cardInfoDisplay": "Отображение информации карточки",
|
"cardInfoDisplay": "Отображение информации карточки",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||||
|
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"checkUpdates": "Проверить обновления для выбранных",
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
"moveAll": "Переместить все в папку",
|
"moveAll": "Переместить все в папку",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "Установить рейтинг контента",
|
"setContentRating": "Установить рейтинг контента",
|
||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
"repairMetadata": "Восстановить метаданные",
|
"repairMetadata": "Восстановить метаданные",
|
||||||
|
"rematchMetadata": "Сопоставить с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
"restoreModel": "Восстановить модель",
|
"restoreModel": "Восстановить модель",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Найдено {count} групп дубликатов",
|
"found": "Найдено {count} групп дубликатов",
|
||||||
|
"noGroups": "Дубликатов с текущим критерием не найдено",
|
||||||
"keepLatest": "Оставить последние версии",
|
"keepLatest": "Оставить последние версии",
|
||||||
"deleteSelected": "Удалить выбранные"
|
"deleteSelected": "Удалить выбранные",
|
||||||
|
"includePromptLabel": "Учитывать запрос при поиске дубликатов",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "Критерий: комбинация LoRA",
|
||||||
|
"loraComboAndPrompt": "Критерий: комбинация LoRA + запрос",
|
||||||
|
"hintLoraCombo": "Рецепты с одинаковыми LoRA и одинаковой силой группируются вместе.",
|
||||||
|
"hintPromptIncluded": "Рецепты группируются только при одинаковых LoRA с одинаковой силой И одинаковом запросе."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "Удалить модель",
|
"title": "Удалить модель",
|
||||||
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?"
|
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "Это действие можно отменить в течение 30 секунд."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "Исключить модель",
|
"title": "Исключить модель",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "Ошибка"
|
"columnError": "Ошибка"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "Сводка пакетной загрузки",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "Успешно",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "Ошибки",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "Всего",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "Все {count} моделей успешно загружены",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "Завершено с ошибками",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "Не удалось загрузить",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "Неудачные элементы ({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "Имя модели",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "Ошибка",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "Закрыть",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "Скопировать отчёт",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "Повторить неудачные ({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||||
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||||
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||||
|
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||||
|
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||||
|
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
||||||
|
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||||
|
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||||
|
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||||
"reimporting": "Переимпорт рецепта из источника...",
|
"reimporting": "Переимпорт рецепта из источника...",
|
||||||
"reimportSuccess": "Рецепт успешно переимпортирован",
|
"reimportSuccess": "Рецепт успешно переимпортирован",
|
||||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
|
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
|
||||||
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
|
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
|
||||||
"presetNameExists": "Пресет с таким именем уже существует",
|
"presetNameExists": "Пресет с таким именем уже существует",
|
||||||
"maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
|
|
||||||
"presetNotFound": "Пресет не найден",
|
"presetNotFound": "Пресет не найден",
|
||||||
"invalidPreset": "Недопустимые данные пресета",
|
"invalidPreset": "Недопустимые данные пресета",
|
||||||
"deletePresetFailed": "Не удалось удалить пресет",
|
"deletePresetFailed": "Не удалось удалить пресет",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "Не удалось обновить триггерные слова",
|
"updateFailed": "Не удалось обновить триггерные слова",
|
||||||
"copyFailed": "Копирование не удалось"
|
"copyFailed": "Копирование не удалось"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "Не удалось загрузить элементы",
|
"loadFailed": "Не удалось загрузить элементы",
|
||||||
"loadMoreFailed": "Не удалось загрузить больше элементов",
|
"loadMoreFailed": "Не удалось загрузить больше элементов",
|
||||||
|
|||||||
+60
-16
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||||
"error": "配方修复失败:{message}"
|
"error": "配方修复失败:{message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "将食谱重新匹配到本地模型",
|
||||||
|
"loading": "正在将食谱重新匹配到本地模型...",
|
||||||
|
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
|
||||||
|
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
|
||||||
|
"allFailed": "{failures}/{total} 个食谱重新匹配失败",
|
||||||
|
"noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
|
||||||
|
"cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。",
|
||||||
|
"error": "食谱重新匹配失败:{message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "管理已排除的模型"
|
"label": "管理已排除的模型"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7(1080p),8(2K),10(4K)"
|
"compact": "7(1080p),8(2K),10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
|
||||||
|
"recipesLayout": "配方布局",
|
||||||
|
"recipesLayoutHelp": "选择配方卡片的排列方式:统一网格,或保留每张图片原始宽高比的瀑布流(Pinterest 风格)布局。",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "网格",
|
||||||
|
"masonry": "瀑布流"
|
||||||
|
},
|
||||||
"showFolderSidebar": "显示文件夹侧边栏",
|
"showFolderSidebar": "显示文件夹侧边栏",
|
||||||
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
|
||||||
"cardInfoDisplay": "卡片信息显示",
|
"cardInfoDisplay": "卡片信息显示",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "复制所选中语法",
|
"copyAll": "复制所选中语法",
|
||||||
"refreshAll": "刷新所选中元数据",
|
"refreshAll": "刷新所选中元数据",
|
||||||
"repairMetadata": "修复所选中元数据",
|
"repairMetadata": "修复所选中元数据",
|
||||||
|
"rematchMetadata": "将所选中重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"checkUpdates": "检查所选更新",
|
"checkUpdates": "检查所选更新",
|
||||||
"moveAll": "移动所选中到文件夹",
|
"moveAll": "移动所选中到文件夹",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "设置内容评级",
|
"setContentRating": "设置内容评级",
|
||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
"repairMetadata": "修复元数据",
|
"repairMetadata": "修复元数据",
|
||||||
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"restoreModel": "恢复模型",
|
"restoreModel": "恢复模型",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "发现 {count} 个重复组",
|
"found": "发现 {count} 个重复组",
|
||||||
|
"noGroups": "按当前判重依据未找到重复组",
|
||||||
"keepLatest": "保留最新版本",
|
"keepLatest": "保留最新版本",
|
||||||
"deleteSelected": "删除已选"
|
"deleteSelected": "删除已选",
|
||||||
|
"includePromptLabel": "将提示词纳入判重",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "判重依据:LoRA 组合",
|
||||||
|
"loraComboAndPrompt": "判重依据:LoRA 组合 + 提示词",
|
||||||
|
"hintLoraCombo": "使用相同 LoRA(强度一致)的配方会被分组。",
|
||||||
|
"hintPromptIncluded": "仅当配方使用相同的 LoRA(强度一致)且提示词相同时才会被分组。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "删除模型",
|
"title": "删除模型",
|
||||||
"message": "你确定要删除此模型及所有相关文件吗?"
|
"message": "你确定要删除此模型及所有相关文件吗?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "此操作可在 30 秒内撤销。"
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "排除模型",
|
"title": "排除模型",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "错误"
|
"columnError": "错误"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "批量下载摘要",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "成功",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "失败",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "总数",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "全部 {count} 个模型下载成功",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "已完成,但有错误",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "下载失败",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "失败项({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "模型名称",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "错误",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "关闭",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "复制报告",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "重试失败项({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||||
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||||
"repairBulkFailed": "修复所选配方失败:{message}",
|
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||||
|
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
|
||||||
|
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
|
||||||
|
"rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败",
|
||||||
|
"rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
|
||||||
|
"rematchSkipped": "{total} 个所选食谱均无需重新匹配",
|
||||||
|
"rematchFailed": "重新匹配所选食谱失败:{message}",
|
||||||
"reimporting": "正在从源重新导入配方...",
|
"reimporting": "正在从源重新导入配方...",
|
||||||
"reimportSuccess": "配方已从源重新导入成功",
|
"reimportSuccess": "配方已从源重新导入成功",
|
||||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "预设名称不能超过 {max} 个字符",
|
"presetNameTooLong": "预设名称不能超过 {max} 个字符",
|
||||||
"presetNameInvalidChars": "预设名称包含无效字符",
|
"presetNameInvalidChars": "预设名称包含无效字符",
|
||||||
"presetNameExists": "已存在同名预设",
|
"presetNameExists": "已存在同名预设",
|
||||||
"maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
|
|
||||||
"presetNotFound": "预设未找到",
|
"presetNotFound": "预设未找到",
|
||||||
"invalidPreset": "无效的预设数据",
|
"invalidPreset": "无效的预设数据",
|
||||||
"deletePresetFailed": "删除预设失败",
|
"deletePresetFailed": "删除预设失败",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "触发词更新失败",
|
"updateFailed": "触发词更新失败",
|
||||||
"copyFailed": "复制失败"
|
"copyFailed": "复制失败"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "加载项目失败",
|
"loadFailed": "加载项目失败",
|
||||||
"loadMoreFailed": "加载更多项目失败",
|
"loadMoreFailed": "加载更多项目失败",
|
||||||
|
|||||||
+61
-17
@@ -186,6 +186,16 @@
|
|||||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||||
"error": "配方修復失敗:{message}"
|
"error": "配方修復失敗:{message}"
|
||||||
},
|
},
|
||||||
|
"rematchRecipes": {
|
||||||
|
"label": "將食譜重新匹配到本地模型",
|
||||||
|
"loading": "正在將食譜重新匹配到本地模型...",
|
||||||
|
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
|
||||||
|
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
|
||||||
|
"allFailed": "{failures}/{total} 個食譜重新匹配失敗",
|
||||||
|
"noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
|
||||||
|
"cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。",
|
||||||
|
"error": "食譜重新匹配失敗:{message}"
|
||||||
|
},
|
||||||
"manageExcludedModels": {
|
"manageExcludedModels": {
|
||||||
"label": "管理已排除的模型"
|
"label": "管理已排除的模型"
|
||||||
},
|
},
|
||||||
@@ -449,6 +459,12 @@
|
|||||||
"compact": "7(1080p)、8(2K)、10(4K)"
|
"compact": "7(1080p)、8(2K)、10(4K)"
|
||||||
},
|
},
|
||||||
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
|
||||||
|
"recipesLayout": "配方版面",
|
||||||
|
"recipesLayoutHelp": "選擇配方卡片的排列方式:統一網格,或保留每張圖片原始寬高比的瀑布流(Pinterest 風格)版面。",
|
||||||
|
"recipesLayoutOptions": {
|
||||||
|
"grid": "網格",
|
||||||
|
"masonry": "瀑布流"
|
||||||
|
},
|
||||||
"showFolderSidebar": "顯示資料夾側邊欄",
|
"showFolderSidebar": "顯示資料夾側邊欄",
|
||||||
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
|
||||||
"cardInfoDisplay": "卡片資訊顯示",
|
"cardInfoDisplay": "卡片資訊顯示",
|
||||||
@@ -687,7 +703,7 @@
|
|||||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||||
"apiKey": "API 金鑰",
|
"apiKey": "API 金鑰",
|
||||||
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
|
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
|
||||||
"apiKeyPlaceholder": "[TODO: Translate] sk-...",
|
"apiKeyPlaceholder": "sk-...",
|
||||||
"apiKeyNotSet": "未設定",
|
"apiKeyNotSet": "未設定",
|
||||||
"apiKeyConfigured": "已設定",
|
"apiKeyConfigured": "已設定",
|
||||||
"apiKeySet": "設定",
|
"apiKeySet": "設定",
|
||||||
@@ -762,6 +778,7 @@
|
|||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
"repairMetadata": "修復所選中元數據",
|
"repairMetadata": "修復所選中元數據",
|
||||||
|
"rematchMetadata": "將所選中重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"checkUpdates": "檢查所選更新",
|
"checkUpdates": "檢查所選更新",
|
||||||
"moveAll": "全部移動到資料夾",
|
"moveAll": "全部移動到資料夾",
|
||||||
@@ -817,6 +834,7 @@
|
|||||||
"setContentRating": "設定內容分級",
|
"setContentRating": "設定內容分級",
|
||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
"repairMetadata": "修復元數據",
|
"repairMetadata": "修復元數據",
|
||||||
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"restoreModel": "還原模型",
|
"restoreModel": "還原模型",
|
||||||
@@ -917,8 +935,16 @@
|
|||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "發現 {count} 組重複項",
|
"found": "發現 {count} 組重複項",
|
||||||
|
"noGroups": "按目前判重依據未找到重複組",
|
||||||
"keepLatest": "保留最新版本",
|
"keepLatest": "保留最新版本",
|
||||||
"deleteSelected": "刪除所選"
|
"deleteSelected": "刪除所選",
|
||||||
|
"includePromptLabel": "將提示詞納入判重",
|
||||||
|
"basis": {
|
||||||
|
"loraCombo": "判重依據:LoRA 組合",
|
||||||
|
"loraComboAndPrompt": "判重依據:LoRA 組合 + 提示詞",
|
||||||
|
"hintLoraCombo": "使用相同 LoRA(強度一致)的配方會被分組。",
|
||||||
|
"hintPromptIncluded": "僅當配方使用相同的 LoRA(強度一致)且提示詞相同時才會被分組。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"copyRecipe": {
|
"copyRecipe": {
|
||||||
@@ -1251,8 +1277,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "[TODO: Translate] Frees {size}",
|
||||||
"title": "刪除模型",
|
"title": "刪除模型",
|
||||||
"message": "您確定要刪除此模型及所有相關檔案嗎?"
|
"message": "您確定要刪除此模型及所有相關檔案嗎?",
|
||||||
|
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "此操作可在 30 秒內復原。"
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "排除模型",
|
"title": "排除模型",
|
||||||
@@ -1584,19 +1615,19 @@
|
|||||||
"columnError": "錯誤"
|
"columnError": "錯誤"
|
||||||
},
|
},
|
||||||
"downloadBatchSummary": {
|
"downloadBatchSummary": {
|
||||||
"title": "[TODO: Translate] Batch Download Summary",
|
"title": "批次下載摘要",
|
||||||
"statSuccess": "[TODO: Translate] Success",
|
"statSuccess": "成功",
|
||||||
"statFailed": "[TODO: Translate] Failed",
|
"statFailed": "失敗",
|
||||||
"statTotal": "[TODO: Translate] Total",
|
"statTotal": "總數",
|
||||||
"successMessage": "[TODO: Translate] All {count} models downloaded successfully",
|
"successMessage": "全部 {count} 個模型下載成功",
|
||||||
"completedWithErrors": "[TODO: Translate] Completed with errors",
|
"completedWithErrors": "已完成,但有錯誤",
|
||||||
"failed": "[TODO: Translate] Download failed",
|
"failed": "下載失敗",
|
||||||
"failedItems": "[TODO: Translate] Failed Items ({count})",
|
"failedItems": "失敗項目({count})",
|
||||||
"columnName": "[TODO: Translate] Model Name",
|
"columnName": "模型名稱",
|
||||||
"columnError": "[TODO: Translate] Error",
|
"columnError": "錯誤",
|
||||||
"close": "[TODO: Translate] Close",
|
"close": "關閉",
|
||||||
"copyReport": "[TODO: Translate] Copy Report",
|
"copyReport": "複製報告",
|
||||||
"retryFailed": "[TODO: Translate] Retry Failed ({count})"
|
"retryFailed": "重試失敗項目({count})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"modelTags": {
|
"modelTags": {
|
||||||
@@ -1945,6 +1976,12 @@
|
|||||||
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||||
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||||
"repairBulkFailed": "修復所選配方失敗:{message}",
|
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||||
|
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
|
||||||
|
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
|
||||||
|
"rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗",
|
||||||
|
"rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
|
||||||
|
"rematchSkipped": "{total} 個所選食譜均無需重新匹配",
|
||||||
|
"rematchFailed": "重新匹配所選食譜失敗:{message}",
|
||||||
"reimporting": "正在從來源重新匯入配方...",
|
"reimporting": "正在從來源重新匯入配方...",
|
||||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||||
@@ -2053,7 +2090,6 @@
|
|||||||
"presetNameTooLong": "預設名稱不能超過 {max} 個字元",
|
"presetNameTooLong": "預設名稱不能超過 {max} 個字元",
|
||||||
"presetNameInvalidChars": "預設名稱包含無效字元",
|
"presetNameInvalidChars": "預設名稱包含無效字元",
|
||||||
"presetNameExists": "已存在同名預設",
|
"presetNameExists": "已存在同名預設",
|
||||||
"maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
|
|
||||||
"presetNotFound": "預設未找到",
|
"presetNotFound": "預設未找到",
|
||||||
"invalidPreset": "無效的預設資料",
|
"invalidPreset": "無效的預設資料",
|
||||||
"deletePresetFailed": "刪除預設失敗",
|
"deletePresetFailed": "刪除預設失敗",
|
||||||
@@ -2082,6 +2118,14 @@
|
|||||||
"updateFailed": "更新觸發詞失敗",
|
"updateFailed": "更新觸發詞失敗",
|
||||||
"copyFailed": "複製失敗"
|
"copyFailed": "複製失敗"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "[TODO: Translate] Undo",
|
||||||
|
"deleted": "[TODO: Translate] Deleted {name}",
|
||||||
|
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
|
||||||
|
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "[TODO: Translate] Undo failed: {error}",
|
||||||
|
"restored": "[TODO: Translate] Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "載入項目失敗",
|
"loadFailed": "載入項目失敗",
|
||||||
"loadMoreFailed": "載入更多項目失敗",
|
"loadMoreFailed": "載入更多項目失敗",
|
||||||
|
|||||||
+15
-10
@@ -1,9 +1,13 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import posixpath
|
import posixpath
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import folder_paths # type: ignore
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
@@ -90,7 +94,7 @@ def _resolve_valid_default_root(
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_folder_paths_for_comparison(
|
def _normalize_folder_paths_for_comparison(
|
||||||
folder_paths: Mapping[str, Iterable[str]],
|
folder_paths: Mapping[str, Any],
|
||||||
) -> Dict[str, Set[str]]:
|
) -> Dict[str, Set[str]]:
|
||||||
"""Normalize folder paths for comparison across libraries."""
|
"""Normalize folder paths for comparison across libraries."""
|
||||||
|
|
||||||
@@ -482,7 +486,7 @@ class Config:
|
|||||||
import ctypes
|
import ctypes
|
||||||
|
|
||||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
||||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # type: ignore[attr-defined]
|
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
|
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking Windows reparse point: {e}")
|
logger.error(f"Error checking Windows reparse point: {e}")
|
||||||
@@ -491,7 +495,7 @@ class Config:
|
|||||||
logger.error(f"Error checking link status for {path}: {e}")
|
logger.error(f"Error checking link status for {path}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _entry_is_symlink(self, entry: os.DirEntry) -> bool:
|
def _entry_is_symlink(self, entry: os.DirEntry[str]) -> bool:
|
||||||
"""Check if a directory entry is a symlink, including Windows junctions."""
|
"""Check if a directory entry is a symlink, including Windows junctions."""
|
||||||
if entry.is_symlink():
|
if entry.is_symlink():
|
||||||
return True
|
return True
|
||||||
@@ -500,7 +504,7 @@ class Config:
|
|||||||
import ctypes
|
import ctypes
|
||||||
|
|
||||||
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
||||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) # type: ignore[attr-defined]
|
attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
|
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -1126,8 +1130,8 @@ class Config:
|
|||||||
|
|
||||||
def _apply_library_paths(
|
def _apply_library_paths(
|
||||||
self,
|
self,
|
||||||
folder_paths: Mapping[str, Iterable[str]],
|
folder_paths: Mapping[str, Any],
|
||||||
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
extra_folder_paths: Optional[Mapping[str, Any]] = None,
|
||||||
recipes_path: str = "",
|
recipes_path: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
self._path_mappings.clear()
|
self._path_mappings.clear()
|
||||||
@@ -1432,12 +1436,13 @@ class Config:
|
|||||||
# ('_lm_config_cache') that is NEVER removed from sys.modules (its key does
|
# ('_lm_config_cache') that is NEVER removed from sys.modules (its key does
|
||||||
# NOT start with 'py.'), so it survives re-imports of py.* modules.
|
# NOT start with 'py.'), so it survives re-imports of py.* modules.
|
||||||
_CONFIG_SENTINEL = "_lm_config_cache"
|
_CONFIG_SENTINEL = "_lm_config_cache"
|
||||||
|
config: Config
|
||||||
if _CONFIG_SENTINEL in _sys.modules:
|
if _CONFIG_SENTINEL in _sys.modules:
|
||||||
# Re-import: reuse the existing singleton from the sentinel.
|
# Re-import: reuse the existing singleton from the sentinel.
|
||||||
config: Config = _sys.modules[_CONFIG_SENTINEL].config # type: ignore[valid-type]
|
config = _sys.modules[_CONFIG_SENTINEL].config
|
||||||
else:
|
else:
|
||||||
config: Config = Config()
|
config = Config()
|
||||||
# Register the sentinel so re-imports of py.config find us.
|
# Register the sentinel so re-imports of py.config find us.
|
||||||
_sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL)
|
_sentinel_mod = _types.ModuleType(_CONFIG_SENTINEL)
|
||||||
_sentinel_mod.config = config
|
setattr(_sentinel_mod, "config", config)
|
||||||
_sys.modules[_CONFIG_SENTINEL] = _sentinel_mod
|
_sys.modules[_CONFIG_SENTINEL] = _sentinel_mod
|
||||||
|
|||||||
+18
-1
@@ -14,7 +14,7 @@ standalone_mode = (
|
|||||||
if not standalone_mode:
|
if not standalone_mode:
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
from .config import config
|
from .config import config
|
||||||
from .services.model_service_factory import (
|
from .services.model_service_factory import (
|
||||||
@@ -25,10 +25,12 @@ from .routes.recipe_routes import RecipeRoutes
|
|||||||
from .routes.stats_routes import StatsRoutes
|
from .routes.stats_routes import StatsRoutes
|
||||||
from .routes.update_routes import UpdateRoutes
|
from .routes.update_routes import UpdateRoutes
|
||||||
from .routes.misc_routes import MiscRoutes
|
from .routes.misc_routes import MiscRoutes
|
||||||
|
from .routes.pending_delete_routes import PendingDeleteRoutes
|
||||||
from .routes.preview_routes import PreviewRoutes
|
from .routes.preview_routes import PreviewRoutes
|
||||||
from .routes.example_images_routes import ExampleImagesRoutes
|
from .routes.example_images_routes import ExampleImagesRoutes
|
||||||
from .services.service_registry import ServiceRegistry
|
from .services.service_registry import ServiceRegistry
|
||||||
from .services.settings_manager import get_settings_manager
|
from .services.settings_manager import get_settings_manager
|
||||||
|
from .services.pending_delete_service import get_pending_delete_service
|
||||||
from .utils.example_images_migration import ExampleImagesMigration
|
from .utils.example_images_migration import ExampleImagesMigration
|
||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||||
@@ -170,6 +172,7 @@ class LoraManager:
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app)
|
MiscRoutes.setup_routes(app)
|
||||||
|
PendingDeleteRoutes.setup_routes(app)
|
||||||
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||||
PreviewRoutes.setup_routes(app)
|
PreviewRoutes.setup_routes(app)
|
||||||
|
|
||||||
@@ -245,6 +248,20 @@ class LoraManager:
|
|||||||
cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks"
|
cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Startup sweep: purge pending-delete batches that expired during a
|
||||||
|
# previous run. Non-blocking (fire-and-forget); purge_expired only
|
||||||
|
# removes already-expired batches, so a staged undo that survived a
|
||||||
|
# restart stays restorable. scan_roots=True runs the reconciliation
|
||||||
|
# pass first so leftover batches (the in-process registry is empty
|
||||||
|
# after a restart) are re-discovered on disk. Covers both plugin
|
||||||
|
# and standalone modes (StandaloneLoraManager reuses this
|
||||||
|
# classmethod).
|
||||||
|
pending_delete_service = await get_pending_delete_service()
|
||||||
|
asyncio.create_task(
|
||||||
|
pending_delete_service.purge_expired(scan_roots=True),
|
||||||
|
name="pending_delete_startup_sweep",
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"LoRA Manager: All services initialized and background tasks scheduled"
|
"LoRA Manager: All services initialized and background tasks scheduled"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ if not standalone_mode:
|
|||||||
|
|
||||||
logger.info("ComfyUI Metadata Collector initialized")
|
logger.info("ComfyUI Metadata Collector initialized")
|
||||||
|
|
||||||
def get_metadata(prompt_id=None): # type: ignore[no-redef]
|
def get_metadata(prompt_id=None): # pyright: ignore[reportRedeclaration]
|
||||||
"""Helper function to get metadata from the registry"""
|
"""Helper function to get metadata from the registry"""
|
||||||
registry = MetadataRegistry()
|
registry = MetadataRegistry()
|
||||||
return registry.get_metadata(prompt_id)
|
return registry.get_metadata(prompt_id)
|
||||||
@@ -31,6 +31,6 @@ else:
|
|||||||
def init():
|
def init():
|
||||||
logger.info("ComfyUI Metadata Collector disabled in standalone mode")
|
logger.info("ComfyUI Metadata Collector disabled in standalone mode")
|
||||||
|
|
||||||
def get_metadata(prompt_id=None): # type: ignore[no-redef]
|
def get_metadata(prompt_id=None): # pyright: ignore[reportRedeclaration]
|
||||||
"""Dummy implementation for standalone mode"""
|
"""Dummy implementation for standalone mode"""
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class MetadataHook:
|
|||||||
execution = None
|
execution = None
|
||||||
try:
|
try:
|
||||||
# Try direct import first
|
# Try direct import first
|
||||||
import execution # type: ignore
|
import execution # pyright: ignore[reportMissingImports]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Try to locate from system modules
|
# Try to locate from system modules
|
||||||
for module_name in sys.modules:
|
for module_name in sys.modules:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
from nodes import NODE_CLASS_MAPPINGS # type: ignore
|
from typing import Any
|
||||||
|
from nodes import NODE_CLASS_MAPPINGS # pyright: ignore[reportMissingImports, reportAttributeAccessIssue]
|
||||||
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
|
||||||
from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
|
from .constants import METADATA_CATEGORIES, IMAGES, OVERWRITE
|
||||||
|
|
||||||
@@ -9,6 +10,15 @@ class MetadataRegistry:
|
|||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
|
|
||||||
|
current_prompt_id: Any = None
|
||||||
|
current_prompt: Any = None
|
||||||
|
metadata: dict[str, Any] = {}
|
||||||
|
prompt_metadata: dict[str, Any] = {}
|
||||||
|
executed_nodes: set[str] = set()
|
||||||
|
node_cache: dict[str, Any] = {}
|
||||||
|
max_prompt_history: int = 3
|
||||||
|
metadata_categories: list[str] = METADATA_CATEGORIES
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
|||||||
|
|
||||||
async def _find_model_entry(
|
async def _find_model_entry(
|
||||||
model_path: str,
|
model_path: str,
|
||||||
) -> tuple[object, object, str | None] | tuple[None, None, None]:
|
) -> tuple[Any, object, str | None] | tuple[None, None, None]:
|
||||||
"""Iterate all scanners and return the first (scanner, entry, getter_name)
|
"""Iterate all scanners and return the first (scanner, entry, getter_name)
|
||||||
that owns *model_path*. Returns ``(None, None, None)`` when no scanner
|
that owns *model_path*. Returns ``(None, None, None)`` when no scanner
|
||||||
claims it.
|
claims it.
|
||||||
@@ -73,7 +73,7 @@ async def _find_model_entry(
|
|||||||
|
|
||||||
async def _find_scanner_for_model(
|
async def _find_scanner_for_model(
|
||||||
model_path: str,
|
model_path: str,
|
||||||
) -> tuple[object, object] | tuple[None, None]:
|
) -> tuple[Any, object] | tuple[None, None]:
|
||||||
"""Find the (scanner, cache_entry) responsible for *model_path*."""
|
"""Find the (scanner, cache_entry) responsible for *model_path*."""
|
||||||
scanner, entry, _ = await _find_model_entry(model_path)
|
scanner, entry, _ = await _find_model_entry(model_path)
|
||||||
return scanner, entry
|
return scanner, entry
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List, Tuple
|
import os
|
||||||
import comfy.sd # type: ignore
|
from typing import Any, List, Tuple
|
||||||
import folder_paths # type: ignore
|
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||||
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,9 +19,9 @@ class CheckpointLoaderLM:
|
|||||||
CATEGORY = "Lora Manager/loaders"
|
CATEGORY = "Lora Manager/loaders"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(s):
|
def INPUT_TYPES(cls):
|
||||||
# Get list of checkpoint names from scanner (includes extra folder paths)
|
# Get list of checkpoint names from scanner (includes extra folder paths)
|
||||||
checkpoint_names = s._get_checkpoint_names()
|
checkpoint_names = cls._get_checkpoint_names()
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"ckpt_name": (
|
"ckpt_name": (
|
||||||
@@ -58,7 +59,10 @@ class CheckpointLoaderLM:
|
|||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("sub_type") == "checkpoint":
|
if item.get("sub_type") == "checkpoint":
|
||||||
file_path = item.get("file_path", "")
|
file_path = item.get("file_path", "")
|
||||||
if file_path:
|
# Only offer models that still exist on disk so ComfyUI
|
||||||
|
# flags missing checkpoints at queue time via
|
||||||
|
# "value not in list" (the scanner cache can be stale).
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
# Format using relative path with OS-native separator
|
# Format using relative path with OS-native separator
|
||||||
formatted_name = _format_model_name_for_comfyui(
|
formatted_name = _format_model_name_for_comfyui(
|
||||||
file_path, model_roots
|
file_path, model_roots
|
||||||
@@ -89,7 +93,7 @@ class CheckpointLoaderLM:
|
|||||||
logger.error(f"Error getting checkpoint names: {e}")
|
logger.error(f"Error getting checkpoint names: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def load_checkpoint(self, ckpt_name: str) -> Tuple:
|
def load_checkpoint(self, ckpt_name: str) -> Tuple[Any, Any, Any]:
|
||||||
"""Load a checkpoint by name, supporting extra folder paths
|
"""Load a checkpoint by name, supporting extra folder paths
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .utils import (
|
|||||||
any_type,
|
any_type,
|
||||||
apply_lora_syntax_format,
|
apply_lora_syntax_format,
|
||||||
get_loras_list,
|
get_loras_list,
|
||||||
|
validate_lora_entries,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -42,6 +43,11 @@ class CreateHookLoraLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||||
FUNCTION = "create_hook"
|
FUNCTION = "create_hook"
|
||||||
@@ -57,8 +63,8 @@ class CreateHookLoraLM:
|
|||||||
del text # used by the frontend widget only
|
del text # used by the frontend widget only
|
||||||
|
|
||||||
# Lazy imports: comfy is not available in CI/test environment at module level
|
# Lazy imports: comfy is not available in CI/test environment at module level
|
||||||
import comfy.hooks # type: ignore # noqa: C0415
|
import comfy.hooks # pyright: ignore[reportMissingImports] # noqa: C0415
|
||||||
import comfy.utils # type: ignore # noqa: C0415
|
import comfy.utils # pyright: ignore[reportMissingImports] # noqa: C0415
|
||||||
|
|
||||||
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
|
prev_hooks: comfy.hooks.HookGroup | None = kwargs.get("prev_hooks")
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import comfy.sd # type: ignore
|
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||||
import comfy.utils # type: ignore
|
import comfy.utils # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
from ..utils.utils import get_lora_info_absolute
|
from ..utils.utils import get_lora_info_absolute
|
||||||
from .utils import (
|
from .utils import (
|
||||||
@@ -14,6 +14,7 @@ from .utils import (
|
|||||||
get_loras_list,
|
get_loras_list,
|
||||||
nunchaku_load_lora,
|
nunchaku_load_lora,
|
||||||
parse_lora_syntax,
|
parse_lora_syntax,
|
||||||
|
validate_lora_entries,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -142,6 +143,11 @@ class LoraLoaderLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
FUNCTION = "load_loras"
|
FUNCTION = "load_loras"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ and tracks the last used combination for reuse.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
|
from .utils import validate_lora_entries
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,6 +32,11 @@ class LoraRandomizerLM:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("LORA_STACK",)
|
RETURN_TYPES = ("LORA_STACK",)
|
||||||
RETURN_NAMES = ("LORA_STACK",)
|
RETURN_NAMES = ("LORA_STACK",)
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class LoraStackCombinerLM:
|
|||||||
|
|
||||||
stack = inspect.stack()
|
stack = inspect.stack()
|
||||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||||
optional_inputs = _LoraStackOptionalInputs(optional_inputs) # type: ignore[assignment]
|
optional_inputs = _LoraStackOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"required": {},
|
"required": {},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list, validate_lora_entries
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -22,6 +22,11 @@ class LoraStackerLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||||
FUNCTION = "stack_loras"
|
FUNCTION = "stack_loras"
|
||||||
|
|||||||
+12
-13
@@ -15,15 +15,15 @@ import os
|
|||||||
import re
|
import re
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
||||||
|
|
||||||
import comfy.utils # type: ignore
|
import comfy.utils # pyright: ignore[reportMissingImports]
|
||||||
import folder_paths # type: ignore
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
from safetensors import safe_open
|
from safetensors import safe_open
|
||||||
|
|
||||||
from nunchaku.lora.flux.nunchaku_converter import (
|
from nunchaku.lora.flux.nunchaku_converter import ( # pyright: ignore[reportMissingTypeStubs]
|
||||||
pack_lowrank_weight,
|
pack_lowrank_weight,
|
||||||
unpack_lowrank_weight,
|
unpack_lowrank_weight,
|
||||||
)
|
)
|
||||||
@@ -87,10 +87,6 @@ def _rename_layer_underscore_layer_name(old_name: str) -> str:
|
|||||||
return new_name
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
def _is_indexable_module(module):
|
|
||||||
return isinstance(module, (nn.ModuleList, nn.Sequential, list, tuple))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
|
def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
|
||||||
if not name:
|
if not name:
|
||||||
return model
|
return model
|
||||||
@@ -100,7 +96,7 @@ def _get_module_by_name(model: nn.Module, name: str) -> Optional[nn.Module]:
|
|||||||
continue
|
continue
|
||||||
if hasattr(module, part):
|
if hasattr(module, part):
|
||||||
module = getattr(module, part)
|
module = getattr(module, part)
|
||||||
elif part.isdigit() and _is_indexable_module(module):
|
elif part.isdigit() and isinstance(module, (nn.ModuleList, nn.Sequential, list, tuple)):
|
||||||
try:
|
try:
|
||||||
module = module[int(part)]
|
module = module[int(part)]
|
||||||
except (IndexError, TypeError):
|
except (IndexError, TypeError):
|
||||||
@@ -267,7 +263,9 @@ def _handle_proj_out_split(lora_dict: Dict[str, Dict[str, torch.Tensor]], base_k
|
|||||||
return result, consumed
|
return result, consumed
|
||||||
|
|
||||||
|
|
||||||
def _apply_lora_to_module(module: nn.Module, a_tensor: torch.Tensor, b_tensor: torch.Tensor, module_name: str, model: nn.Module) -> None:
|
def _apply_lora_to_module(module: Any, a_tensor: torch.Tensor, b_tensor: torch.Tensor, module_name: str, model: Any) -> None:
|
||||||
|
# These modules are dynamic torch containers; monkey-patched attributes
|
||||||
|
# below are set at runtime, so the module/model types are deliberately Any.
|
||||||
if not hasattr(module, "in_features") or not hasattr(module, "out_features"):
|
if not hasattr(module, "in_features") or not hasattr(module, "out_features"):
|
||||||
raise ValueError(f"{module_name}: unsupported module without in/out features")
|
raise ValueError(f"{module_name}: unsupported module without in/out features")
|
||||||
if a_tensor.shape[1] != module.in_features or b_tensor.shape[0] != module.out_features:
|
if a_tensor.shape[1] != module.in_features or b_tensor.shape[0] != module.out_features:
|
||||||
@@ -336,7 +334,7 @@ def _apply_lora_to_module(module: nn.Module, a_tensor: torch.Tensor, b_tensor: t
|
|||||||
raise ValueError(f"{module_name}: unsupported module type {type(module)}")
|
raise ValueError(f"{module_name}: unsupported module type {type(module)}")
|
||||||
|
|
||||||
|
|
||||||
def reset_lora_v2(model: nn.Module) -> None:
|
def reset_lora_v2(model: Any) -> None:
|
||||||
slots = getattr(model, "_lora_slots", None)
|
slots = getattr(model, "_lora_slots", None)
|
||||||
if not slots:
|
if not slots:
|
||||||
return
|
return
|
||||||
@@ -344,6 +342,7 @@ def reset_lora_v2(model: nn.Module) -> None:
|
|||||||
module = _get_module_by_name(model, name)
|
module = _get_module_by_name(model, name)
|
||||||
if module is None:
|
if module is None:
|
||||||
continue
|
continue
|
||||||
|
module = cast(Any, module)
|
||||||
module_type = info.get("type", "nunchaku")
|
module_type = info.get("type", "nunchaku")
|
||||||
if module_type == "nunchaku":
|
if module_type == "nunchaku":
|
||||||
base_rank = info["base_rank"]
|
base_rank = info["base_rank"]
|
||||||
@@ -371,7 +370,7 @@ def reset_lora_v2(model: nn.Module) -> None:
|
|||||||
def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]], apply_awq_mod: bool = True) -> bool:
|
def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]], apply_awq_mod: bool = True) -> bool:
|
||||||
del apply_awq_mod # retained for interface compatibility
|
del apply_awq_mod # retained for interface compatibility
|
||||||
reset_lora_v2(model)
|
reset_lora_v2(model)
|
||||||
aggregated_weights: Dict[str, List[Dict[str, object]]] = defaultdict(list)
|
aggregated_weights: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||||
saw_supported_format = False
|
saw_supported_format = False
|
||||||
unresolved_targets = 0
|
unresolved_targets = 0
|
||||||
|
|
||||||
@@ -471,7 +470,7 @@ def compose_loras_v2(model: nn.Module, lora_configs: List[Tuple[Union[str, Path,
|
|||||||
class ComfyQwenImageWrapperLM(nn.Module):
|
class ComfyQwenImageWrapperLM(nn.Module):
|
||||||
def __init__(self, model: nn.Module, config=None, apply_awq_mod: bool = True):
|
def __init__(self, model: nn.Module, config=None, apply_awq_mod: bool = True):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.model = model
|
self.model: Any = model
|
||||||
self.config = {} if config is None else config
|
self.config = {} if config is None else config
|
||||||
self.dtype = next(model.parameters()).dtype
|
self.dtype = next(model.parameters()).dtype
|
||||||
self.loras: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]] = []
|
self.loras: List[Tuple[Union[str, Path, Dict[str, torch.Tensor]], float]] = []
|
||||||
|
|||||||
+2
-2
@@ -67,7 +67,7 @@ class PromptLM:
|
|||||||
|
|
||||||
stack = inspect.stack()
|
stack = inspect.stack()
|
||||||
if len(stack) > 2 and stack[2].function == "get_input_info":
|
if len(stack) > 2 and stack[2].function == "get_input_info":
|
||||||
optional_inputs = _PromptOptionalInputs(optional_inputs) # type: ignore[assignment]
|
optional_inputs = _PromptOptionalInputs(optional_inputs) # pyright: ignore[reportAssignmentType]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
@@ -126,7 +126,7 @@ class PromptLM:
|
|||||||
else:
|
else:
|
||||||
prompt = expanded_text
|
prompt = expanded_text
|
||||||
|
|
||||||
from nodes import CLIPTextEncode # type: ignore
|
from nodes import CLIPTextEncode # pyright: ignore[reportMissingImports, reportAttributeAccessIssue]
|
||||||
|
|
||||||
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
|
||||||
return (conditioning, prompt)
|
return (conditioning, prompt)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import folder_paths # type: ignore
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..metadata_collector.metadata_processor import MetadataProcessor
|
from ..metadata_collector.metadata_processor import MetadataProcessor
|
||||||
from ..metadata_collector import get_metadata
|
from ..metadata_collector import get_metadata
|
||||||
@@ -13,7 +13,7 @@ from ..utils.constants import CARD_PREVIEW_WIDTH
|
|||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
|
from ..utils.utils import calculate_recipe_fingerprint, sanitize_folder_name
|
||||||
from PIL import Image, PngImagePlugin
|
from PIL import Image, PngImagePlugin
|
||||||
import piexif
|
import piexif # pyright: ignore[reportMissingTypeStubs]
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# Civitai-compatible sampler name mapping: ComfyUI internal → A1111 display name
|
# Civitai-compatible sampler name mapping: ComfyUI internal → A1111 display name
|
||||||
@@ -355,7 +355,7 @@ class SaveImageLM:
|
|||||||
type_lower = model_type.lower() if model_type else "other"
|
type_lower = model_type.lower() if model_type else "other"
|
||||||
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
|
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
|
||||||
|
|
||||||
def format_metadata(self, metadata_dict: dict, add_loras_to_prompt: bool = False) -> str:
|
def format_metadata(self, metadata_dict: dict[str, Any], add_loras_to_prompt: bool = False) -> str:
|
||||||
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
|
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
|
||||||
if not metadata_dict: return ""
|
if not metadata_dict: return ""
|
||||||
|
|
||||||
@@ -396,7 +396,7 @@ class SaveImageLM:
|
|||||||
ckpt_display_name = os.path.splitext(os.path.basename(checkpoint))[0]
|
ckpt_display_name = os.path.splitext(os.path.basename(checkpoint))[0]
|
||||||
|
|
||||||
# Resolve LoRA hash and Civitai data from local cache
|
# Resolve LoRA hash and Civitai data from local cache
|
||||||
loras_data: list[dict] = []
|
loras_data: list[dict[str, Any]] = []
|
||||||
for lora_name, strength in lora_entries:
|
for lora_name, strength in lora_entries:
|
||||||
lora_hash, lora_civitai, lora_base_model = self._resolve_model_cache_entry(
|
lora_hash, lora_civitai, lora_base_model = self._resolve_model_cache_entry(
|
||||||
"lora_scanner", lora_name
|
"lora_scanner", lora_name
|
||||||
@@ -418,9 +418,9 @@ class SaveImageLM:
|
|||||||
hashes[f"LORA:{lora['name']}"] = lora["hash"][:10].upper()
|
hashes[f"LORA:{lora['name']}"] = lora["hash"][:10].upper()
|
||||||
|
|
||||||
# Build Civitai resources JSON array
|
# Build Civitai resources JSON array
|
||||||
civitai_resources: list[dict] = []
|
civitai_resources: list[dict[str, Any]] = []
|
||||||
if ckpt_civitai.get("id", 0) > 0:
|
if ckpt_civitai.get("id", 0) > 0:
|
||||||
ckpt_resource: dict = {}
|
ckpt_resource: dict[str, Any] = {}
|
||||||
ckpt_type = (ckpt_civitai.get("model") or {}).get("type", "Checkpoint")
|
ckpt_type = (ckpt_civitai.get("model") or {}).get("type", "Checkpoint")
|
||||||
model_id = ckpt_civitai.get("modelId", 0)
|
model_id = ckpt_civitai.get("modelId", 0)
|
||||||
version_id = ckpt_civitai.get("id", 0)
|
version_id = ckpt_civitai.get("id", 0)
|
||||||
@@ -439,7 +439,7 @@ class SaveImageLM:
|
|||||||
lora_civitai = lora["civitai"]
|
lora_civitai = lora["civitai"]
|
||||||
if not lora_civitai or lora_civitai.get("id", 0) <= 0:
|
if not lora_civitai or lora_civitai.get("id", 0) <= 0:
|
||||||
continue
|
continue
|
||||||
lora_resource: dict = {"weight": lora["strength"]}
|
lora_resource: dict[str, Any] = {"weight": lora["strength"]}
|
||||||
lora_type = (lora_civitai.get("model") or {}).get("type", "LORA")
|
lora_type = (lora_civitai.get("model") or {}).get("type", "LORA")
|
||||||
model_id = lora_civitai.get("modelId", 0)
|
model_id = lora_civitai.get("modelId", 0)
|
||||||
version_id = lora_civitai.get("id", 0)
|
version_id = lora_civitai.get("id", 0)
|
||||||
|
|||||||
+10
-7
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import List, Tuple
|
from typing import Any, List, Tuple
|
||||||
import comfy.sd # type: ignore
|
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||||
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,9 +34,9 @@ class UNETLoaderLM:
|
|||||||
CATEGORY = "Lora Manager/loaders"
|
CATEGORY = "Lora Manager/loaders"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(s):
|
def INPUT_TYPES(cls):
|
||||||
# Get list of unet names from scanner (includes extra folder paths)
|
# Get list of unet names from scanner (includes extra folder paths)
|
||||||
unet_names = s._get_unet_names()
|
unet_names = cls._get_unet_names()
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"unet_name": (
|
"unet_name": (
|
||||||
@@ -74,7 +74,10 @@ class UNETLoaderLM:
|
|||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("sub_type") == "diffusion_model":
|
if item.get("sub_type") == "diffusion_model":
|
||||||
file_path = item.get("file_path", "")
|
file_path = item.get("file_path", "")
|
||||||
if file_path:
|
# Only offer models that still exist on disk so ComfyUI
|
||||||
|
# flags missing diffusion models at queue time via
|
||||||
|
# "value not in list" (the scanner cache can be stale).
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
# Format using relative path with OS-native separator
|
# Format using relative path with OS-native separator
|
||||||
formatted_name = _format_model_name_for_comfyui(
|
formatted_name = _format_model_name_for_comfyui(
|
||||||
file_path, model_roots
|
file_path, model_roots
|
||||||
@@ -105,7 +108,7 @@ class UNETLoaderLM:
|
|||||||
logger.error(f"Error getting unet names: {e}")
|
logger.error(f"Error getting unet names: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple:
|
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple[Any, ...]:
|
||||||
"""Load a diffusion model by name, supporting extra folder paths
|
"""Load a diffusion model by name, supporting extra folder paths
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -148,7 +151,7 @@ class UNETLoaderLM:
|
|||||||
|
|
||||||
def _load_gguf_unet(
|
def _load_gguf_unet(
|
||||||
self, unet_path: str, unet_name: str, weight_dtype: str
|
self, unet_path: str, unet_name: str, weight_dtype: str
|
||||||
) -> Tuple:
|
) -> Tuple[Any, ...]:
|
||||||
"""Load a GGUF format diffusion model
|
"""Load a GGUF format diffusion model
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
+159
-3
@@ -1,3 +1,6 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class AnyType(str):
|
class AnyType(str):
|
||||||
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
|
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
|
||||||
|
|
||||||
@@ -6,7 +9,7 @@ class AnyType(str):
|
|||||||
|
|
||||||
|
|
||||||
# Credit to Regis Gaughan, III (rgthree)
|
# Credit to Regis Gaughan, III (rgthree)
|
||||||
class FlexibleOptionalInputType(dict):
|
class FlexibleOptionalInputType(dict[str, Any]):
|
||||||
"""A special class to make flexible nodes that pass data to our python handlers.
|
"""A special class to make flexible nodes that pass data to our python handlers.
|
||||||
|
|
||||||
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
|
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
|
||||||
@@ -23,6 +26,7 @@ class FlexibleOptionalInputType(dict):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, type):
|
def __init__(self, type):
|
||||||
|
super().__init__()
|
||||||
self.type = type
|
self.type = type
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
@@ -40,7 +44,8 @@ import re
|
|||||||
import logging
|
import logging
|
||||||
import copy
|
import copy
|
||||||
import sys
|
import sys
|
||||||
import folder_paths # type: ignore
|
import asyncio
|
||||||
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -70,7 +75,7 @@ def extract_lora_name(lora_path):
|
|||||||
return apply_lora_syntax_format(name_no_ext)
|
return apply_lora_syntax_format(name_no_ext)
|
||||||
|
|
||||||
|
|
||||||
def parse_lora_syntax(text: str) -> list[dict]:
|
def parse_lora_syntax(text: str) -> list[dict[str, Any]]:
|
||||||
"""Parse <lora:name:strength> syntax from text input into a list of dicts.
|
"""Parse <lora:name:strength> syntax from text input into a list of dicts.
|
||||||
|
|
||||||
Each entry contains: name, model_strength, clip_strength.
|
Each entry contains: name, model_strength, clip_strength.
|
||||||
@@ -107,6 +112,157 @@ def get_loras_list(kwargs):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_LORA_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".bin")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_lora_extension(name: str) -> str:
|
||||||
|
"""Strip a known LoRA model extension from a name (case-insensitive)."""
|
||||||
|
lowered = name.lower()
|
||||||
|
for ext in _LORA_EXTENSIONS:
|
||||||
|
if lowered.endswith(ext):
|
||||||
|
return name[: -len(ext)]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _find_missing_loras(names: list[str]) -> list[str]:
|
||||||
|
"""Return the names that cannot be resolved to an existing local LoRA file.
|
||||||
|
|
||||||
|
Mirrors the matching semantics of ``get_lora_info_absolute``
|
||||||
|
(py/utils/utils.py): after stripping the extension, a name matches a cached
|
||||||
|
LoRA when it equals the cached file name or the ``folder/file`` path. As a
|
||||||
|
fallback, a name containing a folder that only matches by basename resolves
|
||||||
|
to the first basename match (same behavior as the runtime resolver). Raw
|
||||||
|
absolute paths that exist on disk are always considered available.
|
||||||
|
|
||||||
|
The scanner cache is fetched once for all names; the cache may be stale, so
|
||||||
|
resolved paths are additionally verified with ``os.path.isfile``.
|
||||||
|
"""
|
||||||
|
if not names:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _check() -> list[str]:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
# The scanner cache may not be hydrated yet (startup, library path
|
||||||
|
# change). An empty cache is not authoritative — treat it as "cannot
|
||||||
|
# verify" and skip validation instead of flagging every active LoRA
|
||||||
|
# as missing.
|
||||||
|
if getattr(scanner, "_cache", None) is None or getattr(
|
||||||
|
scanner, "_is_initializing", False
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
lookup = {}
|
||||||
|
basename_candidates = {}
|
||||||
|
for item in cache.raw_data:
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
file_name = item.get("file_name", "")
|
||||||
|
folder = item.get("folder", "")
|
||||||
|
file_name_no_ext = _strip_lora_extension(file_name)
|
||||||
|
path_name_no_ext = (
|
||||||
|
f"{folder}/{file_name_no_ext}".replace("\\", "/")
|
||||||
|
if folder
|
||||||
|
else file_name_no_ext
|
||||||
|
)
|
||||||
|
lookup.setdefault(file_name_no_ext, file_path)
|
||||||
|
lookup.setdefault(path_name_no_ext, file_path)
|
||||||
|
basename_candidates.setdefault(file_name_no_ext, []).append(
|
||||||
|
(folder, file_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for name in names:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
normalized = name.replace("\\", "/")
|
||||||
|
# Raw absolute paths (outside the library) are usable as-is.
|
||||||
|
if os.path.isfile(normalized):
|
||||||
|
continue
|
||||||
|
no_ext = _strip_lora_extension(normalized)
|
||||||
|
file_path = lookup.get(no_ext)
|
||||||
|
if file_path is None and "/" in no_ext:
|
||||||
|
# A name with a folder that matches only by basename resolves
|
||||||
|
# at runtime like get_lora_info_absolute's fallback does:
|
||||||
|
# prefer a candidate whose folder prefixes the name, else the
|
||||||
|
# first basename match.
|
||||||
|
folder, basename = no_ext.rsplit("/", 1)
|
||||||
|
candidates = basename_candidates.get(basename, [])
|
||||||
|
file_path = next(
|
||||||
|
(
|
||||||
|
fp
|
||||||
|
for fld, fp in candidates
|
||||||
|
if fld and no_ext.startswith(fld + "/")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if file_path is None and candidates:
|
||||||
|
file_path = candidates[0][1]
|
||||||
|
if file_path is None or not os.path.isfile(file_path):
|
||||||
|
missing.append(name)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if we're already in an event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# If we're in a running loop, run the async check in a separate thread
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
def run_in_thread():
|
||||||
|
new_loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(new_loop)
|
||||||
|
try:
|
||||||
|
return new_loop.run_until_complete(_check())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop is running, we can use asyncio.run()
|
||||||
|
return asyncio.run(_check())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_lora_entries(kwargs):
|
||||||
|
"""Validate active LoRA widget entries against the local library.
|
||||||
|
|
||||||
|
Used by node ``VALIDATE_INPUTS`` implementations so ComfyUI rejects the
|
||||||
|
prompt at queue time (``custom_validation_failed``) when an active entry
|
||||||
|
references a LoRA that is not available locally — mirroring how built-in
|
||||||
|
loader nodes flag missing models before execution starts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None when every active entry resolves to an existing local file,
|
||||||
|
otherwise a descriptive error string listing the missing LoRAs.
|
||||||
|
Verification failures (e.g. scanner not ready) are treated as valid
|
||||||
|
so queueing is never blocked by validation machinery itself.
|
||||||
|
"""
|
||||||
|
# Missing/empty loras input is always valid; skip get_loras_list so it
|
||||||
|
# does not log a warning for the None case on every queue.
|
||||||
|
if not kwargs.get("loras"):
|
||||||
|
return None
|
||||||
|
loras = get_loras_list(kwargs)
|
||||||
|
active_names = []
|
||||||
|
for lora in loras:
|
||||||
|
if not isinstance(lora, dict):
|
||||||
|
continue
|
||||||
|
if not lora.get("active", False):
|
||||||
|
continue
|
||||||
|
active_names.append(apply_lora_syntax_format(str(lora.get("name") or "")))
|
||||||
|
try:
|
||||||
|
missing = _find_missing_loras(active_names)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to validate LoRA entries against the local library")
|
||||||
|
return None
|
||||||
|
if not missing:
|
||||||
|
return None
|
||||||
|
return "Missing LoRA(s) in local library: " + ", ".join(missing)
|
||||||
|
|
||||||
|
|
||||||
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
|
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
|
||||||
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
|
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
|
||||||
import safetensors.torch
|
import safetensors.torch
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info_absolute
|
from ..utils.utils import get_lora_info_absolute
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, get_loras_list, validate_lora_entries
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -35,6 +35,11 @@ class WanVideoLoraSelectLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||||
FUNCTION = "process_loras"
|
FUNCTION = "process_loras"
|
||||||
|
|||||||
+17
-5
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
"""Base classes for recipe parsers."""
|
"""Base classes for recipe parsers."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -38,7 +42,7 @@ class RecipeMetadataParser(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any] | None, str | None] | Dict[str, Any],
|
||||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Populate a lora entry with information from Civitai API response
|
Populate a lora entry with information from Civitai API response
|
||||||
@@ -175,10 +179,18 @@ class RecipeMetadataParser(ABC):
|
|||||||
lora_entry['localPath'] = local_path
|
lora_entry['localPath'] = local_path
|
||||||
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
|
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
|
||||||
|
|
||||||
# Get thumbnail from local preview if available
|
# Get thumbnail from local preview if available.
|
||||||
|
# Match the cache item by local path first (get_path_by_hash
|
||||||
|
# cascade: 10-char autov2 / 12-char autov3), then by hash.
|
||||||
lora_cache = await lora_scanner.get_cached_data()
|
lora_cache = await lora_scanner.get_cached_data()
|
||||||
lora_item = next((item for item in lora_cache.raw_data
|
h = (lora_entry.get("hash") or "").lower()
|
||||||
if item['sha256'].lower() == lora_entry['hash'].lower()), None)
|
lora_item = next((item for item in lora_cache.raw_data
|
||||||
|
if (item.get("file_path") or "") == local_path), None)
|
||||||
|
if lora_item is None:
|
||||||
|
lora_item = next((item for item in lora_cache.raw_data
|
||||||
|
if (item.get("sha256") or "").lower() == h
|
||||||
|
or (item.get("autov3") or "").lower() == h
|
||||||
|
or (item.get("sha256") or "")[:10].lower() == h), None)
|
||||||
if lora_item and 'preview_url' in lora_item:
|
if lora_item and 'preview_url' in lora_item:
|
||||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -194,7 +206,7 @@ class RecipeMetadataParser(ABC):
|
|||||||
return lora_entry
|
return lora_entry
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any] | Tuple[Dict[str, Any] | None, str | None] | None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Populate checkpoint information from Civitai API response
|
Populate checkpoint information from Civitai API response
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Factory for creating recipe metadata parsers."""
|
"""Factory for creating recipe metadata parsers."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
from .parsers import (
|
from .parsers import (
|
||||||
RecipeFormatParser,
|
RecipeFormatParser,
|
||||||
ComfyMetadataParser,
|
ComfyMetadataParser,
|
||||||
@@ -31,7 +32,8 @@ class RecipeParserFactory:
|
|||||||
# First, try CivitaiApiMetadataParser for dict input
|
# First, try CivitaiApiMetadataParser for dict input
|
||||||
if isinstance(metadata, dict):
|
if isinstance(metadata, dict):
|
||||||
try:
|
try:
|
||||||
if CivitaiApiMetadataParser().is_metadata_matching(metadata):
|
user_comment: Any = metadata
|
||||||
|
if CivitaiApiMetadataParser().is_metadata_matching(user_comment):
|
||||||
return CivitaiApiMetadataParser()
|
return CivitaiApiMetadataParser()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"CivitaiApiMetadataParser check failed: {e}")
|
logger.debug(f"CivitaiApiMetadataParser check failed: {e}")
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
|||||||
negative_and_params = ""
|
negative_and_params = ""
|
||||||
|
|
||||||
# Initialize metadata
|
# Initialize metadata
|
||||||
metadata = {
|
metadata: Dict[str, Any] = {
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"loras": []
|
"loras": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Union
|
from typing import Dict, Any, Union
|
||||||
from ..base import RecipeMetadataParser
|
from ..base import RecipeMetadataParser
|
||||||
from ..constants import GEN_PARAM_KEYS
|
from ..constants import GEN_PARAM_KEYS, VALID_LORA_TYPES
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
from ...config import config
|
from ...config import config
|
||||||
|
|
||||||
@@ -14,15 +14,16 @@ logger = logging.getLogger(__name__)
|
|||||||
class CivitaiApiMetadataParser(RecipeMetadataParser):
|
class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||||
"""Parser for Civitai image metadata format"""
|
"""Parser for Civitai image metadata format"""
|
||||||
|
|
||||||
def is_metadata_matching(self, metadata) -> bool:
|
def is_metadata_matching(self, user_comment) -> bool:
|
||||||
"""Check if the metadata matches the Civitai image metadata format
|
"""Check if the metadata matches the Civitai image metadata format
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
metadata: The metadata from the image (dict)
|
user_comment: The metadata from the image (dict)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if this parser can handle the metadata
|
bool: True if this parser can handle the metadata
|
||||||
"""
|
"""
|
||||||
|
metadata = user_comment
|
||||||
if not metadata or not isinstance(metadata, dict):
|
if not metadata or not isinstance(metadata, dict):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def parse_metadata( # type: ignore[override]
|
async def parse_metadata( # pyright: ignore[reportIncompatibleMethodOverride]
|
||||||
self, user_comment, recipe_scanner=None, civitai_client=None,
|
self, user_comment, recipe_scanner=None, civitai_client=None,
|
||||||
local_cache: dict[str, Any] | None = None,
|
local_cache: dict[str, Any] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -89,8 +90,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
Returns:
|
Returns:
|
||||||
Dict containing parsed recipe data
|
Dict containing parsed recipe data
|
||||||
"""
|
"""
|
||||||
metadata: Dict[str, Any] = user_comment # type: ignore[assignment]
|
metadata: Dict[str, Any] = user_comment
|
||||||
metadata = user_comment
|
|
||||||
try:
|
try:
|
||||||
# Get metadata provider instead of using civitai_client directly
|
# Get metadata provider instead of using civitai_client directly
|
||||||
metadata_provider = await get_default_metadata_provider()
|
metadata_provider = await get_default_metadata_provider()
|
||||||
@@ -116,7 +116,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
metadata = inner_meta
|
metadata = inner_meta
|
||||||
|
|
||||||
# Initialize result structure
|
# Initialize result structure
|
||||||
result = {
|
result: Dict[str, Any] = {
|
||||||
"base_model": None,
|
"base_model": None,
|
||||||
"loras": [],
|
"loras": [],
|
||||||
"model": None,
|
"model": None,
|
||||||
@@ -125,10 +125,10 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Track already added LoRAs to prevent duplicates
|
# Track already added LoRAs to prevent duplicates
|
||||||
added_loras = {} # key: model_version_id or hash, value: index in result["loras"]
|
added_loras: Dict[str, Any] = {} # key: model_version_id or hash, value: index in result["loras"]
|
||||||
|
|
||||||
# Extract hash information from hashes field for LoRA matching
|
# Extract hash information from hashes field for LoRA matching
|
||||||
lora_hashes = {}
|
lora_hashes: Dict[str, Any] = {}
|
||||||
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
|
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
|
||||||
for key, hash_value in metadata["hashes"].items():
|
for key, hash_value in metadata["hashes"].items():
|
||||||
key_str = str(key)
|
key_str = str(key)
|
||||||
@@ -184,7 +184,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
if model_info:
|
if model_info:
|
||||||
result["base_model"] = model_info.get("baseModel", "")
|
result["base_model"] = model_info.get("baseModel", "")
|
||||||
|
|
||||||
base_model_counts = {}
|
base_model_counts: Dict[str, int] = {}
|
||||||
|
|
||||||
# Process standard resources array
|
# Process standard resources array
|
||||||
if "resources" in metadata and isinstance(metadata["resources"], list):
|
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||||
@@ -196,7 +196,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# identification because it has an explicit type field and hash,
|
# identification because it has an explicit type field and hash,
|
||||||
# unlike modelVersionIds which is a flat list with no type info.
|
# unlike modelVersionIds which is a flat list with no type info.
|
||||||
if resource_type == "model":
|
if resource_type == "model":
|
||||||
checkpoint_entry = {
|
checkpoint_entry: Dict[str, Any] = {
|
||||||
"id": 0,
|
"id": 0,
|
||||||
"modelId": 0,
|
"modelId": 0,
|
||||||
"name": resource.get("name", "Unknown Model"),
|
"name": resource.get("name", "Unknown Model"),
|
||||||
@@ -216,7 +216,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Try to look up base model from the checkpoint hash
|
# Try to look up base model from the checkpoint hash
|
||||||
cp_hash = checkpoint_entry.get("hash")
|
cp_hash = checkpoint_entry.get("hash")
|
||||||
if cp_hash and metadata_provider:
|
if cp_hash and metadata_provider:
|
||||||
local_cached = local_cache.get(cp_hash) if local_cache else None
|
# local_cache keys are stored lowercase
|
||||||
|
local_cached = local_cache.get(cp_hash.lower()) if local_cache else None
|
||||||
if local_cached:
|
if local_cached:
|
||||||
self._populate_entry_from_cache(
|
self._populate_entry_from_cache(
|
||||||
checkpoint_entry, local_cached
|
checkpoint_entry, local_cached
|
||||||
@@ -294,8 +295,15 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
# Try to get info from Civitai if hash is available
|
# Try to get info from Civitai if hash is available
|
||||||
if lora_hash and metadata_provider:
|
if lora_hash and metadata_provider:
|
||||||
local_cached = local_cache.get(lora_hash) if local_cache else None
|
# local_cache keys are stored lowercase
|
||||||
|
local_cached = local_cache.get(lora_hash.lower()) if local_cache else None
|
||||||
if local_cached:
|
if local_cached:
|
||||||
|
cached_type = self._cache_item_model_type(local_cached)
|
||||||
|
if cached_type and cached_type not in VALID_LORA_TYPES:
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping non-LoRA cache item for hash {lora_hash}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
self._populate_entry_from_cache(
|
self._populate_entry_from_cache(
|
||||||
lora_entry, local_cached
|
lora_entry, local_cached
|
||||||
)
|
)
|
||||||
@@ -304,6 +312,12 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
added_loras[str(lora_entry["id"])] = len(
|
added_loras[str(lora_entry["id"])] = len(
|
||||||
result["loras"]
|
result["loras"]
|
||||||
)
|
)
|
||||||
|
# Mirror base.py:150-151 counts for API-path loras
|
||||||
|
bm = local_cached.get("base_model") or ""
|
||||||
|
if bm:
|
||||||
|
base_model_counts[bm] = base_model_counts.get(
|
||||||
|
bm, 0
|
||||||
|
) + 1
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
civitai_info = (
|
civitai_info = (
|
||||||
@@ -649,30 +663,47 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if metadata_provider:
|
if metadata_provider:
|
||||||
try:
|
# local_cache keys are stored lowercase
|
||||||
civitai_info = await metadata_provider.get_model_by_hash(
|
local_cached = local_cache.get(lora_hash.lower()) if local_cache else None
|
||||||
lora_hash
|
if local_cached:
|
||||||
)
|
cached_type = self._cache_item_model_type(local_cached)
|
||||||
|
if cached_type and cached_type not in VALID_LORA_TYPES:
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
logger.debug(
|
||||||
lora_entry,
|
f"Skipping non-LoRA cache item for hash {lora_hash}"
|
||||||
civitai_info,
|
)
|
||||||
recipe_scanner,
|
|
||||||
base_model_counts,
|
|
||||||
lora_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
if populated_entry is None:
|
|
||||||
continue
|
continue
|
||||||
|
self._populate_entry_from_cache(lora_entry, local_cached)
|
||||||
lora_entry = populated_entry
|
# Mirror base.py:150-151 counts for API-path loras
|
||||||
|
bm = local_cached.get("base_model") or ""
|
||||||
|
if bm:
|
||||||
|
base_model_counts[bm] = base_model_counts.get(bm, 0) + 1
|
||||||
if "id" in lora_entry and lora_entry["id"]:
|
if "id" in lora_entry and lora_entry["id"]:
|
||||||
added_loras[str(lora_entry["id"])] = len(result["loras"])
|
added_loras[str(lora_entry["id"])] = len(result["loras"])
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(
|
try:
|
||||||
f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}"
|
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"])
|
added_loras[lora_hash] = len(result["loras"])
|
||||||
result["loras"].append(lora_entry)
|
result["loras"].append(lora_entry)
|
||||||
@@ -711,32 +742,51 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
# Try to get info from Civitai if hash is available
|
# Try to get info from Civitai if hash is available
|
||||||
if lora_entry["hash"] and metadata_provider:
|
if lora_entry["hash"] and metadata_provider:
|
||||||
try:
|
# local_cache keys are stored lowercase
|
||||||
civitai_info = await metadata_provider.get_model_by_hash(
|
local_cached = local_cache.get(lora_hash.lower()) if local_cache else None
|
||||||
lora_hash
|
if local_cached:
|
||||||
)
|
cached_type = self._cache_item_model_type(local_cached)
|
||||||
|
if cached_type and cached_type not in VALID_LORA_TYPES:
|
||||||
populated_entry = await self.populate_lora_from_civitai(
|
logger.debug(
|
||||||
lora_entry,
|
f"Skipping non-LoRA cache item for hash {lora_hash}"
|
||||||
civitai_info,
|
)
|
||||||
recipe_scanner,
|
|
||||||
base_model_counts,
|
|
||||||
lora_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
if populated_entry is None:
|
|
||||||
lora_index += 1
|
lora_index += 1
|
||||||
continue # Skip invalid LoRA types
|
continue # Skip non-LoRA cache items
|
||||||
|
self._populate_entry_from_cache(lora_entry, local_cached)
|
||||||
lora_entry = populated_entry
|
# Mirror base.py:150-151 counts for API-path loras
|
||||||
|
bm = local_cached.get("base_model") or ""
|
||||||
|
if bm:
|
||||||
|
base_model_counts[bm] = base_model_counts.get(bm, 0) + 1
|
||||||
# If we have a version ID from Civitai, track it for deduplication
|
# If we have a version ID from Civitai, track it for deduplication
|
||||||
if "id" in lora_entry and lora_entry["id"]:
|
if "id" in lora_entry and lora_entry["id"]:
|
||||||
added_loras[str(lora_entry["id"])] = len(result["loras"])
|
added_loras[str(lora_entry["id"])] = len(result["loras"])
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(
|
try:
|
||||||
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
|
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:
|
||||||
|
lora_index += 1
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
|
||||||
|
# If we have a version ID from Civitai, track it for deduplication
|
||||||
|
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_entry['hash']}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Track by hash if we have it
|
# Track by hash if we have it
|
||||||
if lora_hash:
|
if lora_hash:
|
||||||
@@ -795,3 +845,14 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
base_model = cache_item.get("base_model", "")
|
base_model = cache_item.get("base_model", "")
|
||||||
if base_model:
|
if base_model:
|
||||||
entry["baseModel"] = base_model
|
entry["baseModel"] = base_model
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_item_model_type(cache_item: dict[str, Any]) -> str:
|
||||||
|
"""Lowercased civitai.model.type of a cache item, or '' when unknown."""
|
||||||
|
civ = cache_item.get("civitai")
|
||||||
|
if not isinstance(civ, dict):
|
||||||
|
return ""
|
||||||
|
model_info = civ.get("model")
|
||||||
|
if not isinstance(model_info, dict):
|
||||||
|
return ""
|
||||||
|
return (model_info.get("type") or "").lower()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class MetaFormatParser(RecipeMetadataParser):
|
|||||||
prompt = parts[0].strip()
|
prompt = parts[0].strip()
|
||||||
|
|
||||||
# Initialize metadata
|
# Initialize metadata
|
||||||
metadata = {"prompt": prompt, "loras": []}
|
metadata: Dict[str, Any] = {"prompt": prompt, "loras": []}
|
||||||
|
|
||||||
# Extract negative prompt and parameters if available
|
# Extract negative prompt and parameters if available
|
||||||
if len(parts) > 1:
|
if len(parts) > 1:
|
||||||
|
|||||||
@@ -91,7 +91,15 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
exists_locally = lora_scanner.has_hash(lora['hash'])
|
exists_locally = lora_scanner.has_hash(lora['hash'])
|
||||||
if exists_locally:
|
if exists_locally:
|
||||||
lora_cache = await lora_scanner.get_cached_data()
|
lora_cache = await lora_scanner.get_cached_data()
|
||||||
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
# Cascade match: full sha256, stored autov3, or autov2 (sha256[:10]).
|
||||||
|
h = (lora.get('hash') or '').lower()
|
||||||
|
lora_item = next(
|
||||||
|
(item for item in lora_cache.raw_data
|
||||||
|
if (item.get("sha256") or "").lower() == h
|
||||||
|
or (item.get("autov3") or "").lower() == h
|
||||||
|
or (item.get("sha256") or "")[:10].lower() == h),
|
||||||
|
None
|
||||||
|
)
|
||||||
if lora_item:
|
if lora_item:
|
||||||
lora_entry['existsLocally'] = True
|
lora_entry['existsLocally'] = True
|
||||||
lora_entry['inLibrary'] = True
|
lora_entry['inLibrary'] = True
|
||||||
@@ -148,7 +156,7 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
checkpoint_data = recipe_metadata.get('checkpoint') or {}
|
checkpoint_data = recipe_metadata.get('checkpoint') or {}
|
||||||
if isinstance(checkpoint_data, dict) and checkpoint_data:
|
if isinstance(checkpoint_data, dict) and checkpoint_data:
|
||||||
version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id')
|
version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id')
|
||||||
checkpoint_entry = {
|
checkpoint_entry: Dict[str, Any] = {
|
||||||
'id': version_id or 0,
|
'id': version_id or 0,
|
||||||
'modelId': checkpoint_data.get('modelId', 0),
|
'modelId': checkpoint_data.get('modelId', 0),
|
||||||
'name': checkpoint_data.get('name', 'Unknown Checkpoint'),
|
'name': checkpoint_data.get('name', 'Unknown Checkpoint'),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import TYPE_CHECKING, Callable, Dict, Mapping
|
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Mapping
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -84,7 +84,7 @@ class BaseModelRoutes(ABC):
|
|||||||
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
||||||
|
|
||||||
self._handler_set: ModelHandlerSet | None = None
|
self._handler_set: ModelHandlerSet | None = None
|
||||||
self._handler_mapping: Dict[str, Callable[[web.Request], web.StreamResponse]] | None = None
|
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
|
||||||
|
|
||||||
self._preview_service = PreviewAssetService(
|
self._preview_service = PreviewAssetService(
|
||||||
metadata_manager=MetadataManager,
|
metadata_manager=MetadataManager,
|
||||||
@@ -131,7 +131,7 @@ class BaseModelRoutes(ABC):
|
|||||||
self._handler_set = None
|
self._handler_set = None
|
||||||
self._handler_mapping = None
|
self._handler_mapping = None
|
||||||
|
|
||||||
def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]:
|
def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
if self._handler_mapping is None:
|
if self._handler_mapping is None:
|
||||||
handler_set = self._create_handler_set()
|
handler_set = self._create_handler_set()
|
||||||
self._handler_set = handler_set
|
self._handler_set = handler_set
|
||||||
@@ -220,7 +220,7 @@ class BaseModelRoutes(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def route_handlers(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]:
|
def route_handlers(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
return self._ensure_handler_mapping()
|
return self._ensure_handler_mapping()
|
||||||
|
|
||||||
def setup_routes(self, app: web.Application, prefix: str) -> None:
|
def setup_routes(self, app: web.Application, prefix: str) -> None:
|
||||||
@@ -237,7 +237,7 @@ class BaseModelRoutes(ABC):
|
|||||||
"""Setup model-specific routes."""
|
"""Setup model-specific routes."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
|
||||||
"""Parse model-specific parameters - to be overridden by subclasses."""
|
"""Parse model-specific parameters - to be overridden by subclasses."""
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ class BaseModelRoutes(ABC):
|
|||||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||||
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
|
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
|
||||||
|
|
||||||
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
def get_handler(self, name: str) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
|
||||||
"""Expose handlers for subclasses or tests."""
|
"""Expose handlers for subclasses or tests."""
|
||||||
return self._ensure_handler_mapping()[name]
|
return self._ensure_handler_mapping()[name]
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ class BaseModelRoutes(ABC):
|
|||||||
)
|
)
|
||||||
return self.model_lifecycle_service
|
return self.model_lifecycle_service
|
||||||
|
|
||||||
def _make_handler_proxy(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
|
def _make_handler_proxy(self, name: str) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
|
||||||
async def proxy(request: web.Request) -> web.StreamResponse:
|
async def proxy(request: web.Request) -> web.StreamResponse:
|
||||||
try:
|
try:
|
||||||
handler = self.get_handler(name)
|
handler = self.get_handler(name)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Callable, Mapping
|
from typing import Awaitable, Callable, Mapping
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -61,7 +61,9 @@ class BaseRecipeRoutes:
|
|||||||
self._i18n_registered = False
|
self._i18n_registered = False
|
||||||
self._startup_hooks_registered = False
|
self._startup_hooks_registered = False
|
||||||
self._handler_set: RecipeHandlerSet | None = None
|
self._handler_set: RecipeHandlerSet | None = None
|
||||||
self._handler_mapping: dict[str, Callable] | None = None
|
self._handler_mapping: Mapping[
|
||||||
|
str, Callable[[web.Request], Awaitable[web.StreamResponse]]
|
||||||
|
] | None = None
|
||||||
|
|
||||||
async def attach_dependencies(self, app: web.Application | None = None) -> None:
|
async def attach_dependencies(self, app: web.Application | None = None) -> None:
|
||||||
"""Resolve shared services from the registry."""
|
"""Resolve shared services from the registry."""
|
||||||
@@ -84,7 +86,9 @@ class BaseRecipeRoutes:
|
|||||||
app.on_startup.append(self.attach_dependencies)
|
app.on_startup.append(self.attach_dependencies)
|
||||||
self._startup_hooks_registered = True
|
self._startup_hooks_registered = True
|
||||||
|
|
||||||
def to_route_mapping(self) -> Mapping[str, Callable]:
|
def to_route_mapping(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
"""Return a mapping of handler name to coroutine for registrar binding."""
|
"""Return a mapping of handler name to coroutine for registrar binding."""
|
||||||
|
|
||||||
if self._handler_mapping is None:
|
if self._handler_mapping is None:
|
||||||
@@ -124,17 +128,17 @@ class BaseRecipeRoutes:
|
|||||||
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||||
)
|
)
|
||||||
if not standalone_mode:
|
if not standalone_mode:
|
||||||
from ..metadata_collector import get_metadata # type: ignore[import-not-found]
|
from ..metadata_collector import get_metadata # pyright: ignore[reportMissingImports]
|
||||||
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]
|
from ..metadata_collector.metadata_processor import ( # pyright: ignore[reportMissingImports]
|
||||||
MetadataProcessor,
|
MetadataProcessor,
|
||||||
)
|
)
|
||||||
from ..metadata_collector.metadata_registry import ( # type: ignore[import-not-found]
|
from ..metadata_collector.metadata_registry import ( # pyright: ignore[reportMissingImports]
|
||||||
MetadataRegistry,
|
MetadataRegistry,
|
||||||
)
|
)
|
||||||
else: # pragma: no cover - optional dependency path
|
else: # pragma: no cover - optional dependency path
|
||||||
get_metadata = None # type: ignore[assignment]
|
get_metadata = None # pyright: ignore[reportAssignmentType]
|
||||||
MetadataProcessor = None # type: ignore[assignment]
|
MetadataProcessor = None # pyright: ignore[reportAssignmentType]
|
||||||
MetadataRegistry = None # type: ignore[assignment]
|
MetadataRegistry = None # pyright: ignore[reportAssignmentType]
|
||||||
|
|
||||||
analysis_service = RecipeAnalysisService(
|
analysis_service = RecipeAnalysisService(
|
||||||
exif_utils=ExifUtils,
|
exif_utils=ExifUtils,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Set
|
from typing import Any, Dict, List, Set
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from .base_model_routes import BaseModelRoutes
|
from .base_model_routes import BaseModelRoutes
|
||||||
@@ -28,13 +28,13 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
# Attach service dependencies
|
# Attach service dependencies
|
||||||
self.attach_service(self.service)
|
self.attach_service(self.service)
|
||||||
|
|
||||||
def setup_routes(self, app: web.Application):
|
def setup_routes(self, app: web.Application, prefix: str = "checkpoints"):
|
||||||
"""Setup Checkpoint routes"""
|
"""Setup Checkpoint routes"""
|
||||||
# Schedule service initialization on app startup
|
# Schedule service initialization on app startup
|
||||||
app.on_startup.append(lambda _: self.initialize_services())
|
app.on_startup.append(lambda _: self.initialize_services())
|
||||||
|
|
||||||
# Setup common routes with 'checkpoints' prefix (includes page route)
|
# Setup common routes with 'checkpoints' prefix (includes page route)
|
||||||
super().setup_routes(app, 'checkpoints')
|
super().setup_routes(app, prefix)
|
||||||
|
|
||||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
"""Setup Checkpoint-specific routes"""
|
"""Setup Checkpoint-specific routes"""
|
||||||
@@ -53,9 +53,9 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
"""Get expected model types string for error messages"""
|
"""Get expected model types string for error messages"""
|
||||||
return "Checkpoint"
|
return "Checkpoint"
|
||||||
|
|
||||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
|
||||||
"""Parse Checkpoint-specific parameters"""
|
"""Parse Checkpoint-specific parameters"""
|
||||||
params: Dict = {}
|
params: Dict[str, Any] = {}
|
||||||
|
|
||||||
if 'checkpoint_hash' in request.query:
|
if 'checkpoint_hash' in request.query:
|
||||||
params['hash_filters'] = {'single_hash': request.query['checkpoint_hash'].lower()}
|
params['hash_filters'] = {'single_hash': request.query['checkpoint_hash'].lower()}
|
||||||
@@ -70,7 +70,7 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
"""Get detailed information for a specific checkpoint by name"""
|
"""Get detailed information for a specific checkpoint by name"""
|
||||||
try:
|
try:
|
||||||
name = request.match_info.get('name', '')
|
name = request.match_info.get('name', '')
|
||||||
checkpoint_info = await self.service.get_model_info_by_name(name)
|
checkpoint_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
if checkpoint_info:
|
if checkpoint_info:
|
||||||
return web.json_response(checkpoint_info)
|
return web.json_response(checkpoint_info)
|
||||||
@@ -89,7 +89,7 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
roots.extend(config.checkpoints_roots or [])
|
roots.extend(config.checkpoints_roots or [])
|
||||||
roots.extend(config.extra_checkpoints_roots or [])
|
roots.extend(config.extra_checkpoints_roots or [])
|
||||||
# Remove duplicates while preserving order
|
# Remove duplicates while preserving order
|
||||||
seen: set = set()
|
seen: set[str] = set()
|
||||||
unique_roots: List[str] = []
|
unique_roots: List[str] = []
|
||||||
for root in roots:
|
for root in roots:
|
||||||
if root and root not in seen:
|
if root and root not in seen:
|
||||||
@@ -114,7 +114,7 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
roots.extend(config.unet_roots or [])
|
roots.extend(config.unet_roots or [])
|
||||||
roots.extend(config.extra_unet_roots or [])
|
roots.extend(config.extra_unet_roots or [])
|
||||||
# Remove duplicates while preserving order
|
# Remove duplicates while preserving order
|
||||||
seen: set = set()
|
seen: set[str] = set()
|
||||||
unique_roots: List[str] = []
|
unique_roots: List[str] = []
|
||||||
for root in roots:
|
for root in roots:
|
||||||
if root and root not in seen:
|
if root and root not in seen:
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ class EmbeddingRoutes(BaseModelRoutes):
|
|||||||
# Attach service dependencies
|
# Attach service dependencies
|
||||||
self.attach_service(self.service)
|
self.attach_service(self.service)
|
||||||
|
|
||||||
def setup_routes(self, app: web.Application):
|
def setup_routes(self, app: web.Application, prefix: str = "embeddings"):
|
||||||
"""Setup Embedding routes"""
|
"""Setup Embedding routes"""
|
||||||
# Schedule service initialization on app startup
|
# Schedule service initialization on app startup
|
||||||
app.on_startup.append(lambda _: self.initialize_services())
|
app.on_startup.append(lambda _: self.initialize_services())
|
||||||
|
|
||||||
# Setup common routes with 'embeddings' prefix (includes page route)
|
# Setup common routes with 'embeddings' prefix (includes page route)
|
||||||
super().setup_routes(app, 'embeddings')
|
super().setup_routes(app, prefix)
|
||||||
|
|
||||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
"""Setup Embedding-specific routes"""
|
"""Setup Embedding-specific routes"""
|
||||||
@@ -51,7 +51,7 @@ class EmbeddingRoutes(BaseModelRoutes):
|
|||||||
"""Get detailed information for a specific embedding by name"""
|
"""Get detailed information for a specific embedding by name"""
|
||||||
try:
|
try:
|
||||||
name = request.match_info.get('name', '')
|
name = request.match_info.get('name', '')
|
||||||
embedding_info = await self.service.get_model_info_by_name(name)
|
embedding_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
if embedding_info:
|
if embedding_info:
|
||||||
return web.json_response(embedding_info)
|
return web.json_response(embedding_info)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable, Mapping
|
from typing import Any, Awaitable, Callable, Mapping
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ class ExampleImagesRoutes:
|
|||||||
*,
|
*,
|
||||||
ws_manager,
|
ws_manager,
|
||||||
download_manager: DownloadManager | None = None,
|
download_manager: DownloadManager | None = None,
|
||||||
processor=ExampleImagesProcessor,
|
processor: Any = ExampleImagesProcessor,
|
||||||
file_manager=ExampleImagesFileManager,
|
file_manager=ExampleImagesFileManager,
|
||||||
cleanup_service: ExampleImagesCleanupService | None = None,
|
cleanup_service: ExampleImagesCleanupService | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -46,7 +46,9 @@ class ExampleImagesRoutes:
|
|||||||
self._file_manager = file_manager
|
self._file_manager = file_manager
|
||||||
self._cleanup_service = cleanup_service or ExampleImagesCleanupService()
|
self._cleanup_service = cleanup_service or ExampleImagesCleanupService()
|
||||||
self._handler_set: ExampleImagesHandlerSet | None = None
|
self._handler_set: ExampleImagesHandlerSet | None = None
|
||||||
self._handler_mapping: Mapping[str, Callable[[web.Request], web.StreamResponse]] | None = None
|
self._handler_mapping: Mapping[
|
||||||
|
str, Callable[[web.Request], Awaitable[web.StreamResponse]]
|
||||||
|
] | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setup_routes(cls, app: web.Application, *, ws_manager) -> None:
|
def setup_routes(cls, app: web.Application, *, ws_manager) -> None:
|
||||||
@@ -61,7 +63,9 @@ class ExampleImagesRoutes:
|
|||||||
registrar = ExampleImagesRouteRegistrar(app)
|
registrar = ExampleImagesRouteRegistrar(app)
|
||||||
registrar.register_routes(self.to_route_mapping())
|
registrar.register_routes(self.to_route_mapping())
|
||||||
|
|
||||||
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]:
|
def to_route_mapping(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
"""Return the registrar-compatible mapping of handler names to callables."""
|
"""Return the registrar-compatible mapping of handler names to callables."""
|
||||||
|
|
||||||
if self._handler_mapping is None:
|
if self._handler_mapping is None:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Mapping
|
from typing import Awaitable, Callable, Mapping
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ class ExampleImagesHandlerSet:
|
|||||||
management: ExampleImagesManagementHandler
|
management: ExampleImagesManagementHandler
|
||||||
files: ExampleImagesFileHandler
|
files: ExampleImagesFileHandler
|
||||||
|
|
||||||
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], web.StreamResponse]]:
|
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||||
"""Flatten handler methods into the registrar mapping."""
|
"""Flatten handler methods into the registrar mapping."""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ def _collect_comfyui_session_logs(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if log_entries is None:
|
if log_entries is None:
|
||||||
try:
|
try:
|
||||||
import app.logger as comfy_logger
|
import app.logger as comfy_logger # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
log_entries = list(comfy_logger.get_logs() or [])
|
log_entries = list(comfy_logger.get_logs() or [])
|
||||||
except Exception as exc: # pragma: no cover - environment dependent
|
except Exception as exc: # pragma: no cover - environment dependent
|
||||||
@@ -422,10 +422,10 @@ class PromptServerProtocol(Protocol):
|
|||||||
"""Subset of PromptServer used by the handlers."""
|
"""Subset of PromptServer used by the handlers."""
|
||||||
|
|
||||||
instance: "PromptServerProtocol"
|
instance: "PromptServerProtocol"
|
||||||
sockets: dict # maps clientId (sid) → WebSocketResponse
|
sockets: dict[str, Any] # maps clientId (sid) → WebSocketResponse
|
||||||
|
|
||||||
def send_sync(
|
def send_sync(
|
||||||
self, event: str, payload: dict | None = None, sid: str | None = None
|
self, event: str, payload: dict[str, Any] | None = None, sid: str | None = None
|
||||||
) -> None: # pragma: no cover - protocol
|
) -> None: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -443,7 +443,12 @@ class UsageStatsFactory(Protocol):
|
|||||||
class MetadataProviderProtocol(Protocol):
|
class MetadataProviderProtocol(Protocol):
|
||||||
async def get_model_versions(
|
async def get_model_versions(
|
||||||
self, model_id: int
|
self, model_id: int
|
||||||
) -> dict | None: # pragma: no cover - protocol
|
) -> dict[str, Any] | None: # pragma: no cover - protocol
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_user_models(
|
||||||
|
self, username: str, cursor: str | None = None
|
||||||
|
) -> Any: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@@ -466,16 +471,16 @@ class MetadataArchiveManagerProtocol(Protocol):
|
|||||||
class BackupServiceProtocol(Protocol):
|
class BackupServiceProtocol(Protocol):
|
||||||
async def create_snapshot(
|
async def create_snapshot(
|
||||||
self, *, snapshot_type: str = "manual", persist: bool = False
|
self, *, snapshot_type: str = "manual", persist: bool = False
|
||||||
) -> dict: # pragma: no cover - protocol
|
) -> dict[str, Any]: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
async def restore_snapshot(self, archive_path: str) -> dict: # pragma: no cover - protocol
|
async def restore_snapshot(self, archive_path: str) -> dict[str, Any]: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_status(self) -> dict: # pragma: no cover - protocol
|
def get_status(self) -> dict[str, Any]: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
def get_available_snapshots(self) -> list[dict]: # pragma: no cover - protocol
|
def get_available_snapshots(self) -> list[dict[str, Any]]: # pragma: no cover - protocol
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@@ -491,7 +496,7 @@ class NodeRegistry:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
# sid → {unique_id → node_info}
|
# sid → {unique_id → node_info}
|
||||||
self._tab_nodes: Dict[str, Dict[str, dict]] = {}
|
self._tab_nodes: Dict[str, Dict[str, dict[str, Any]]] = {}
|
||||||
self._ready = asyncio.Event()
|
self._ready = asyncio.Event()
|
||||||
self._waiting_clients: set[str] = set()
|
self._waiting_clients: set[str] = set()
|
||||||
|
|
||||||
@@ -504,7 +509,7 @@ class NodeRegistry:
|
|||||||
# Helpers to build one node dict (extracted so it's reused for each tab)
|
# Helpers to build one node dict (extracted so it's reused for each tab)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_node_dict(node: dict) -> dict:
|
def _build_node_dict(node: dict[str, Any]) -> dict[str, Any]:
|
||||||
node_id = node["node_id"]
|
node_id = node["node_id"]
|
||||||
graph_id = str(node["graph_id"])
|
graph_id = str(node["graph_id"])
|
||||||
unique_id = f"{graph_id}:{node_id}"
|
unique_id = f"{graph_id}:{node_id}"
|
||||||
@@ -513,11 +518,11 @@ class NodeRegistry:
|
|||||||
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
|
||||||
|
|
||||||
raw_capabilities = node.get("capabilities")
|
raw_capabilities = node.get("capabilities")
|
||||||
capabilities: dict = {}
|
capabilities: dict[str, Any] = {}
|
||||||
if isinstance(raw_capabilities, dict):
|
if isinstance(raw_capabilities, dict):
|
||||||
capabilities = dict(raw_capabilities)
|
capabilities = dict(raw_capabilities)
|
||||||
|
|
||||||
raw_widget_names: list | None = node.get("widget_names")
|
raw_widget_names: list[Any] | None = node.get("widget_names")
|
||||||
if not isinstance(raw_widget_names, list):
|
if not isinstance(raw_widget_names, list):
|
||||||
capability_widget_names = capabilities.get("widget_names")
|
capability_widget_names = capabilities.get("widget_names")
|
||||||
raw_widget_names = (
|
raw_widget_names = (
|
||||||
@@ -565,9 +570,9 @@ class NodeRegistry:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async def register_nodes(self, sid: str, nodes: list[dict]) -> None:
|
async def register_nodes(self, sid: str, nodes: list[dict[str, Any]]) -> None:
|
||||||
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
|
"""Register/replace the node list for a single ComfyUI tab (identified by *sid*)."""
|
||||||
tab_nodes: dict[str, dict] = {}
|
tab_nodes: dict[str, dict[str, Any]] = {}
|
||||||
for node in nodes:
|
for node in nodes:
|
||||||
nd = self._build_node_dict(node)
|
nd = self._build_node_dict(node)
|
||||||
tab_nodes[nd["unique_id"]] = nd
|
tab_nodes[nd["unique_id"]] = nd
|
||||||
@@ -602,7 +607,7 @@ class NodeRegistry:
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict:
|
async def get_merged_registry(self, active_sids: set[str] | None = None) -> dict[str, Any]:
|
||||||
"""Return the union of all known tab nodes, pruning any tab that is no
|
"""Return the union of all known tab nodes, pruning any tab that is no
|
||||||
longer connected."""
|
longer connected."""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
@@ -619,8 +624,8 @@ class NodeRegistry:
|
|||||||
len(stale_sids), stale_sids,
|
len(stale_sids), stale_sids,
|
||||||
)
|
)
|
||||||
|
|
||||||
merged: dict[str, dict] = {}
|
merged: dict[str, dict[str, Any]] = {}
|
||||||
tab_info: dict[str, dict] = {}
|
tab_info: dict[str, dict[str, Any]] = {}
|
||||||
for sid, nodes in self._tab_nodes.items():
|
for sid, nodes in self._tab_nodes.items():
|
||||||
tab_info[sid] = {
|
tab_info[sid] = {
|
||||||
"node_count": len(nodes),
|
"node_count": len(nodes),
|
||||||
@@ -653,7 +658,7 @@ class SupportersHandler:
|
|||||||
def __init__(self, logger: logging.Logger | None = None) -> None:
|
def __init__(self, logger: logging.Logger | None = None) -> None:
|
||||||
self._logger = logger or logging.getLogger(__name__)
|
self._logger = logger or logging.getLogger(__name__)
|
||||||
|
|
||||||
def _load_supporters(self) -> dict:
|
def _load_supporters(self) -> dict[str, Any]:
|
||||||
"""Load supporters data from JSON file."""
|
"""Load supporters data from JSON file."""
|
||||||
try:
|
try:
|
||||||
current_file = os.path.abspath(__file__)
|
current_file = os.path.abspath(__file__)
|
||||||
@@ -1229,10 +1234,8 @@ class DoctorHandler:
|
|||||||
settings_snapshot = _sanitize_sensitive_data(
|
settings_snapshot = _sanitize_sensitive_data(
|
||||||
getattr(self._settings, "settings", {}) or {}
|
getattr(self._settings, "settings", {}) or {}
|
||||||
)
|
)
|
||||||
startup_messages_getter = getattr(self._settings, "get_startup_messages", None)
|
startup_messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
|
||||||
startup_messages = (
|
startup_messages = list(startup_messages_getter()) if startup_messages_getter else []
|
||||||
list(startup_messages_getter()) if callable(startup_messages_getter) else []
|
|
||||||
)
|
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
"app_version": app_version,
|
"app_version": app_version,
|
||||||
@@ -1439,7 +1442,7 @@ class SettingsHandler:
|
|||||||
*,
|
*,
|
||||||
settings_service=None,
|
settings_service=None,
|
||||||
metadata_provider_updater: Callable[
|
metadata_provider_updater: Callable[
|
||||||
[], Awaitable[None]
|
[], Awaitable[Any]
|
||||||
] = update_metadata_providers,
|
] = update_metadata_providers,
|
||||||
downloader_factory: Callable[
|
downloader_factory: Callable[
|
||||||
[], Awaitable[DownloaderProtocol]
|
[], Awaitable[DownloaderProtocol]
|
||||||
@@ -1484,8 +1487,8 @@ class SettingsHandler:
|
|||||||
settings_file = getattr(self._settings, "settings_file", None)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
if settings_file:
|
if settings_file:
|
||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
messages_getter = getattr(self._settings, "get_startup_messages", None)
|
messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
|
||||||
messages = list(messages_getter()) if callable(messages_getter) else []
|
messages = list(messages_getter()) if messages_getter else []
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -2005,11 +2008,11 @@ async def _noop_backup_service() -> None:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ServiceRegistryAdapter:
|
class ServiceRegistryAdapter:
|
||||||
get_lora_scanner: Callable[[], Awaitable]
|
get_lora_scanner: Callable[[], Awaitable[Any]]
|
||||||
get_checkpoint_scanner: Callable[[], Awaitable]
|
get_checkpoint_scanner: Callable[[], Awaitable[Any]]
|
||||||
get_embedding_scanner: Callable[[], Awaitable]
|
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
||||||
get_downloaded_version_history_service: Callable[[], Awaitable]
|
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
||||||
get_backup_service: Callable[[], Awaitable] = _noop_backup_service
|
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
||||||
|
|
||||||
|
|
||||||
class ModelLibraryHandler:
|
class ModelLibraryHandler:
|
||||||
@@ -2050,8 +2053,8 @@ class ModelLibraryHandler:
|
|||||||
return await self._service_registry.get_downloaded_version_history_service()
|
return await self._service_registry.get_downloaded_version_history_service()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _with_downloaded_flag(versions: list[dict]) -> list[dict]:
|
def _with_downloaded_flag(versions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
enriched: list[dict] = []
|
enriched: list[dict[str, Any]] = []
|
||||||
for version in versions:
|
for version in versions:
|
||||||
entry = dict(version)
|
entry = dict(version)
|
||||||
entry.setdefault("hasBeenDownloaded", True)
|
entry.setdefault("hasBeenDownloaded", True)
|
||||||
@@ -2244,7 +2247,7 @@ class ModelLibraryHandler:
|
|||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
|
||||||
results: list[dict] = []
|
results: list[dict[str, Any]] = []
|
||||||
for model_id in model_ids:
|
for model_id in model_ids:
|
||||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
if lora_versions:
|
if lora_versions:
|
||||||
@@ -2353,7 +2356,7 @@ class ModelLibraryHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
model_version_id = int(data.get("modelVersionId"))
|
model_version_id = int(data.get("modelVersionId")) # pyright: ignore[reportArgumentType]
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
{"success": False, "error": "Parameter modelVersionId must be an integer"},
|
||||||
@@ -2465,10 +2468,11 @@ class ModelLibraryHandler:
|
|||||||
"checkpoint": checkpoint_scanner,
|
"checkpoint": checkpoint_scanner,
|
||||||
"embedding": embedding_scanner,
|
"embedding": embedding_scanner,
|
||||||
}
|
}
|
||||||
scanner = scanner_map.get(found_type)
|
scanner = scanner_map.get(found_type or "")
|
||||||
if scanner:
|
if scanner:
|
||||||
persist = getattr(scanner, "_persist_current_cache", None)
|
scanner.bump_cache_version()
|
||||||
if callable(persist):
|
persist: Any = getattr(scanner, "_persist_current_cache", None)
|
||||||
|
if persist:
|
||||||
await persist()
|
await persist()
|
||||||
|
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
@@ -2649,13 +2653,13 @@ class ModelLibraryHandler:
|
|||||||
}
|
}
|
||||||
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
||||||
|
|
||||||
type_scanner_map: Dict[str, object | None] = {
|
type_scanner_map: Dict[str, Any] = {
|
||||||
**{alias: lora_scanner for alias in lora_type_aliases},
|
**{alias: lora_scanner for alias in lora_type_aliases},
|
||||||
"checkpoint": checkpoint_scanner,
|
"checkpoint": checkpoint_scanner,
|
||||||
"textualinversion": embedding_scanner,
|
"textualinversion": embedding_scanner,
|
||||||
}
|
}
|
||||||
|
|
||||||
versions: list[dict] = []
|
versions: list[dict[str, Any]] = []
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
model_ids: list[int] = []
|
model_ids: list[int] = []
|
||||||
model_count = 0
|
model_count = 0
|
||||||
@@ -2707,6 +2711,8 @@ class ModelLibraryHandler:
|
|||||||
tags_value = model.get("tags")
|
tags_value = model.get("tags")
|
||||||
tags = tags_value if isinstance(tags_value, list) else []
|
tags = tags_value if isinstance(tags_value, list) else []
|
||||||
model_id = model.get("id")
|
model_id = model.get("id")
|
||||||
|
if model_id is None:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
model_id_int = int(model_id)
|
model_id_int = int(model_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -2722,6 +2728,8 @@ class ModelLibraryHandler:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
version_id = version.get("id")
|
version_id = version.get("id")
|
||||||
|
if version_id is None:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
version_id_int = int(version_id)
|
version_id_int = int(version_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -2783,7 +2791,7 @@ class MetadataArchiveHandler:
|
|||||||
] = get_metadata_archive_manager,
|
] = get_metadata_archive_manager,
|
||||||
settings_service=None,
|
settings_service=None,
|
||||||
metadata_provider_updater: Callable[
|
metadata_provider_updater: Callable[
|
||||||
[], Awaitable[None]
|
[], Awaitable[Any]
|
||||||
] = update_metadata_providers,
|
] = update_metadata_providers,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._metadata_archive_manager_factory = metadata_archive_manager_factory
|
self._metadata_archive_manager_factory = metadata_archive_manager_factory
|
||||||
@@ -2930,7 +2938,7 @@ class BackupHandler:
|
|||||||
|
|
||||||
if request.content_type.startswith("multipart/"):
|
if request.content_type.startswith("multipart/"):
|
||||||
reader = await request.multipart()
|
reader = await request.multipart()
|
||||||
field = await reader.next()
|
field: Any = await reader.next()
|
||||||
uploaded = False
|
uploaded = False
|
||||||
while field is not None:
|
while field is not None:
|
||||||
if getattr(field, "filename", None):
|
if getattr(field, "filename", None):
|
||||||
@@ -3549,7 +3557,7 @@ class NodeRegistryHandler:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
parsed_node_id = node_identifier
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
payload: dict = {
|
payload: dict[str, Any] = {
|
||||||
"id": parsed_node_id,
|
"id": parsed_node_id,
|
||||||
"value": value,
|
"value": value,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
@@ -3673,7 +3681,7 @@ class NodeRegistryHandler:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
parsed_node_id = node_identifier
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
payload: dict = {
|
payload: dict[str, Any] = {
|
||||||
"id": parsed_node_id,
|
"id": parsed_node_id,
|
||||||
"value": value,
|
"value": value,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
@@ -3740,8 +3748,8 @@ class MiscHandlerSet:
|
|||||||
doctor: DoctorHandler,
|
doctor: DoctorHandler,
|
||||||
example_workflows: ExampleWorkflowsHandler,
|
example_workflows: ExampleWorkflowsHandler,
|
||||||
base_model: BaseModelHandlerSet,
|
base_model: BaseModelHandlerSet,
|
||||||
hf_handler: HfHandler | None = None,
|
hf_handler: Any = None,
|
||||||
agent_handler: AgentHandler | None = None,
|
agent_handler: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.health = health
|
self.health = health
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|||||||
@@ -51,6 +51,29 @@ LICENSE_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_broadcast_models_changed_tasks: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_models_changed() -> None:
|
||||||
|
"""Notify connected clients that the local model library changed.
|
||||||
|
|
||||||
|
The ComfyUI graph page listens for this event to invalidate its cached
|
||||||
|
model availability data (loras widget missing-model cues / error flags)
|
||||||
|
without waiting for the cache TTL to expire.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ...services.websocket_manager import ws_manager
|
||||||
|
|
||||||
|
task = asyncio.create_task(ws_manager.broadcast({"type": "models_changed"}))
|
||||||
|
# Keep a reference so the task is not garbage-collected mid-await.
|
||||||
|
_broadcast_models_changed_tasks.add(task)
|
||||||
|
task.add_done_callback(_broadcast_models_changed_tasks.discard)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).debug(
|
||||||
|
"Failed to broadcast models_changed", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelPageView:
|
class ModelPageView:
|
||||||
"""Render the HTML view for model listings."""
|
"""Render the HTML view for model listings."""
|
||||||
|
|
||||||
@@ -71,7 +94,7 @@ class ModelPageView:
|
|||||||
self._server_i18n = server_i18n
|
self._server_i18n = server_i18n
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
def _load_supporters(self) -> dict:
|
def _load_supporters(self) -> dict[str, Any]:
|
||||||
"""Load supporters data from JSON file."""
|
"""Load supporters data from JSON file."""
|
||||||
try:
|
try:
|
||||||
current_file = os.path.abspath(__file__)
|
current_file = os.path.abspath(__file__)
|
||||||
@@ -152,7 +175,7 @@ class ModelPageView:
|
|||||||
self._template_env.filters["t"] = (
|
self._template_env.filters["t"] = (
|
||||||
self._server_i18n.create_template_filter()
|
self._server_i18n.create_template_filter()
|
||||||
)
|
)
|
||||||
self._template_env._i18n_filter_added = True # type: ignore[attr-defined]
|
self._template_env._i18n_filter_added = True # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
from ...services.llm_service import PROVIDER_PRESETS
|
from ...services.llm_service import PROVIDER_PRESETS
|
||||||
|
|
||||||
@@ -199,7 +222,7 @@ class ModelListingHandler:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
service,
|
service,
|
||||||
parse_specific_params: Callable[[web.Request], Dict],
|
parse_specific_params: Callable[[web.Request], Dict[str, Any]],
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._service = service
|
self._service = service
|
||||||
@@ -287,7 +310,7 @@ class ModelListingHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
def _parse_common_params(self, request: web.Request) -> Dict:
|
def _parse_common_params(self, request: web.Request) -> Dict[str, Any]:
|
||||||
page = int(request.query.get("page", "1"))
|
page = int(request.query.get("page", "1"))
|
||||||
page_size = min(int(request.query.get("page_size", "20")), 100)
|
page_size = min(int(request.query.get("page_size", "20")), 100)
|
||||||
sort_by = request.query.get("sort_by", "name")
|
sort_by = request.query.get("sort_by", "name")
|
||||||
@@ -460,6 +483,7 @@ class ModelManagementHandler:
|
|||||||
return web.Response(text="Model path is required", status=400)
|
return web.Response(text="Model path is required", status=400)
|
||||||
|
|
||||||
result = await self._lifecycle_service.delete_model(file_path)
|
result = await self._lifecycle_service.delete_model(file_path)
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
@@ -658,7 +682,7 @@ class ModelManagementHandler:
|
|||||||
try:
|
try:
|
||||||
reader = await request.multipart()
|
reader = await request.multipart()
|
||||||
|
|
||||||
field = await reader.next()
|
field: Any = await reader.next()
|
||||||
if field is None or field.name != "preview_file":
|
if field is None or field.name != "preview_file":
|
||||||
raise ValueError("Expected 'preview_file' field")
|
raise ValueError("Expected 'preview_file' field")
|
||||||
content_type = field.headers.get("Content-Type", "image/png")
|
content_type = field.headers.get("Content-Type", "image/png")
|
||||||
@@ -700,7 +724,7 @@ class ModelManagementHandler:
|
|||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"preview_url": config.get_preview_static_url(
|
"preview_url": config.get_preview_static_url(
|
||||||
result["preview_path"]
|
str(result["preview_path"])
|
||||||
),
|
),
|
||||||
"preview_nsfw_level": result["preview_nsfw_level"],
|
"preview_nsfw_level": result["preview_nsfw_level"],
|
||||||
}
|
}
|
||||||
@@ -781,7 +805,7 @@ class ModelManagementHandler:
|
|||||||
|
|
||||||
result = await self._preview_service.replace_preview(
|
result = await self._preview_service.replace_preview(
|
||||||
model_path=model_path,
|
model_path=model_path,
|
||||||
preview_data=preview_data,
|
preview_data=preview_bytes,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
original_filename=original_filename,
|
original_filename=original_filename,
|
||||||
nsfw_level=nsfw_level,
|
nsfw_level=nsfw_level,
|
||||||
@@ -793,7 +817,7 @@ class ModelManagementHandler:
|
|||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"preview_url": config.get_preview_static_url(
|
"preview_url": config.get_preview_static_url(
|
||||||
result["preview_path"]
|
str(result["preview_path"])
|
||||||
),
|
),
|
||||||
"preview_nsfw_level": result["preview_nsfw_level"],
|
"preview_nsfw_level": result["preview_nsfw_level"],
|
||||||
}
|
}
|
||||||
@@ -931,6 +955,8 @@ class ModelManagementHandler:
|
|||||||
file_path=file_path, new_file_name=new_file_name
|
file_path=file_path, new_file_name=new_file_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_broadcast_models_changed()
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
**result,
|
**result,
|
||||||
@@ -959,6 +985,7 @@ class ModelManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await self._lifecycle_service.bulk_delete_models(file_paths)
|
result = await self._lifecycle_service.bulk_delete_models(file_paths)
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
@@ -1061,6 +1088,7 @@ class ModelQueryHandler:
|
|||||||
await self._service.scan_models(
|
await self._service.scan_models(
|
||||||
force_refresh=True, rebuild_cache=full_rebuild
|
force_refresh=True, rebuild_cache=full_rebuild
|
||||||
)
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1488,8 +1516,73 @@ class ModelQueryHandler:
|
|||||||
search = request.query.get("search", "").strip()
|
search = request.query.get("search", "").strip()
|
||||||
limit = min(int(request.query.get("limit", "15")), 100)
|
limit = min(int(request.query.get("limit", "15")), 100)
|
||||||
offset = max(0, int(request.query.get("offset", "0")))
|
offset = max(0, int(request.query.get("offset", "0")))
|
||||||
|
|
||||||
|
folder = request.query.get("folder")
|
||||||
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||||
|
base_models = list(request.query.getall("base_model", []))
|
||||||
|
model_types = list(request.query.getall("model_type", []))
|
||||||
|
|
||||||
|
tag_filters: Dict[str, str] = {}
|
||||||
|
for tag in request.query.getall("tag_include", []):
|
||||||
|
if tag:
|
||||||
|
tag_filters[tag] = "include"
|
||||||
|
for tag in request.query.getall("tag_exclude", []):
|
||||||
|
if tag:
|
||||||
|
tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
auto_tag_filters: Dict[str, str] = {}
|
||||||
|
for tag in request.query.getall("auto_tag_include", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "include"
|
||||||
|
for tag in request.query.getall("auto_tag_exclude", []):
|
||||||
|
if tag:
|
||||||
|
auto_tag_filters[tag] = "exclude"
|
||||||
|
|
||||||
|
tag_logic = request.query.get("tag_logic", "any").lower()
|
||||||
|
if tag_logic not in ("any", "all"):
|
||||||
|
tag_logic = "any"
|
||||||
|
|
||||||
|
credit_required = request.query.get("credit_required")
|
||||||
|
if credit_required is not None:
|
||||||
|
credit_required = credit_required.lower() not in ("false", "0", "")
|
||||||
|
|
||||||
|
allow_selling_generated_content = request.query.get(
|
||||||
|
"allow_selling_generated_content"
|
||||||
|
)
|
||||||
|
if allow_selling_generated_content is not None:
|
||||||
|
allow_selling_generated_content = (
|
||||||
|
allow_selling_generated_content.lower() not in ("false", "0", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# The presence of the recursive param (always sent by the loras
|
||||||
|
# widget when filter mode is on) signals that the filter pipeline
|
||||||
|
# must run even when no concrete filter is set, so global settings
|
||||||
|
# like show_only_sfw stay consistent with the list endpoint.
|
||||||
|
apply_filters = (
|
||||||
|
"recursive" in request.query
|
||||||
|
or folder is not None
|
||||||
|
or bool(base_models)
|
||||||
|
or bool(model_types)
|
||||||
|
or bool(tag_filters)
|
||||||
|
or bool(auto_tag_filters)
|
||||||
|
or credit_required is not None
|
||||||
|
or allow_selling_generated_content is not None
|
||||||
|
)
|
||||||
|
|
||||||
matching_paths = await self._service.search_relative_paths(
|
matching_paths = await self._service.search_relative_paths(
|
||||||
search, limit, offset
|
search,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
folder=folder,
|
||||||
|
recursive=recursive,
|
||||||
|
base_models=base_models,
|
||||||
|
model_types=model_types,
|
||||||
|
tags=tag_filters,
|
||||||
|
auto_tags=auto_tag_filters,
|
||||||
|
tag_logic=tag_logic,
|
||||||
|
credit_required=credit_required,
|
||||||
|
allow_selling_generated_content=allow_selling_generated_content,
|
||||||
|
apply_filters=apply_filters,
|
||||||
)
|
)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "relative_paths": matching_paths}
|
{"success": True, "relative_paths": matching_paths}
|
||||||
@@ -1995,7 +2088,7 @@ class ModelCivitaiHandler:
|
|||||||
settings_service: SettingsManager,
|
settings_service: SettingsManager,
|
||||||
ws_manager: WebSocketManager,
|
ws_manager: WebSocketManager,
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
metadata_provider_factory: Callable[[], Awaitable],
|
metadata_provider_factory: Callable[[], Awaitable[Any]],
|
||||||
validate_model_type: Callable[[str], bool],
|
validate_model_type: Callable[[str], bool],
|
||||||
expected_model_types: Callable[[], str],
|
expected_model_types: Callable[[], str],
|
||||||
find_model_file: Callable[
|
find_model_file: Callable[
|
||||||
@@ -2060,7 +2153,7 @@ class ModelCivitaiHandler:
|
|||||||
downloaded_version_ids = set(
|
downloaded_version_ids = set(
|
||||||
await history_service.get_downloaded_version_ids(
|
await history_service.get_downloaded_version_ids(
|
||||||
self._service.model_type,
|
self._service.model_type,
|
||||||
model_id,
|
int(model_id),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
@@ -2170,6 +2263,8 @@ class ModelMoveHandler:
|
|||||||
result = await self._move_service.move_model(
|
result = await self._move_service.move_model(
|
||||||
file_path, target_path, use_default_paths=use_default_paths
|
file_path, target_path, use_default_paths=use_default_paths
|
||||||
)
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
_broadcast_models_changed()
|
||||||
status = 200 if result.get("success") else 500
|
status = 200 if result.get("success") else 500
|
||||||
return web.json_response(result, status=status)
|
return web.json_response(result, status=status)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -2189,6 +2284,8 @@ class ModelMoveHandler:
|
|||||||
result = await self._move_service.move_models_bulk(
|
result = await self._move_service.move_models_bulk(
|
||||||
file_paths, target_path, use_default_paths=use_default_paths
|
file_paths, target_path, use_default_paths=use_default_paths
|
||||||
)
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error moving models in bulk: %s", exc, exc_info=True)
|
self._logger.error("Error moving models in bulk: %s", exc, exc_info=True)
|
||||||
@@ -2234,6 +2331,7 @@ class ModelAutoOrganizeHandler:
|
|||||||
progress_callback=self._progress_callback,
|
progress_callback=self._progress_callback,
|
||||||
exclusion_patterns=exclusion_patterns,
|
exclusion_patterns=exclusion_patterns,
|
||||||
)
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result.to_dict())
|
return web.json_response(result.to_dict())
|
||||||
except AutoOrganizeInProgressError:
|
except AutoOrganizeInProgressError:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2337,8 +2435,8 @@ class ModelUpdateHandler:
|
|||||||
self._logger.error("Failed to fetch license info: %s", exc, exc_info=True)
|
self._logger.error("Failed to fetch license info: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
updated: List[Dict[str, str]] = []
|
updated: List[Dict[str, Any]] = []
|
||||||
errors: List[Dict[str, str]] = []
|
errors: List[Dict[str, Any]] = []
|
||||||
for model_id in model_ids:
|
for model_id in model_ids:
|
||||||
license_payload = license_map.get(model_id)
|
license_payload = license_map.get(model_id)
|
||||||
if not license_payload:
|
if not license_payload:
|
||||||
@@ -2351,6 +2449,7 @@ class ModelUpdateHandler:
|
|||||||
model_section = civitai_section.get("model")
|
model_section = civitai_section.get("model")
|
||||||
if not isinstance(model_section, Mapping):
|
if not isinstance(model_section, Mapping):
|
||||||
model_section = {}
|
model_section = {}
|
||||||
|
model_section = dict(model_section)
|
||||||
model_section.update(resolved_payload)
|
model_section.update(resolved_payload)
|
||||||
civitai_section["model"] = model_section
|
civitai_section["model"] = model_section
|
||||||
metadata_payload["civitai"] = civitai_section
|
metadata_payload["civitai"] = civitai_section
|
||||||
@@ -2366,7 +2465,7 @@ class ModelUpdateHandler:
|
|||||||
)
|
)
|
||||||
errors.append({"filePath": metadata_path, "error": str(exc)})
|
errors.append({"filePath": metadata_path, "error": str(exc)})
|
||||||
|
|
||||||
response_payload = {"success": True, "updated": updated}
|
response_payload: Dict[str, Any] = {"success": True, "updated": updated}
|
||||||
missing_model_ids = [mid for mid in model_ids if mid not in license_map]
|
missing_model_ids = [mid for mid in model_ids if mid not in license_map]
|
||||||
if missing_model_ids:
|
if missing_model_ids:
|
||||||
response_payload["missingModelIds"] = missing_model_ids
|
response_payload["missingModelIds"] = missing_model_ids
|
||||||
@@ -2715,6 +2814,7 @@ class ModelUpdateHandler:
|
|||||||
civitai_payload = metadata_payload.get("civitai")
|
civitai_payload = metadata_payload.get("civitai")
|
||||||
if not isinstance(civitai_payload, Mapping):
|
if not isinstance(civitai_payload, Mapping):
|
||||||
civitai_payload = {}
|
civitai_payload = {}
|
||||||
|
civitai_payload = dict(civitai_payload)
|
||||||
|
|
||||||
model_payload = civitai_payload.get("model")
|
model_payload = civitai_payload.get("model")
|
||||||
if not isinstance(model_payload, Mapping):
|
if not isinstance(model_payload, Mapping):
|
||||||
@@ -2759,7 +2859,7 @@ class ModelUpdateHandler:
|
|||||||
|
|
||||||
return aggregated
|
return aggregated
|
||||||
|
|
||||||
def _extract_target_model_ids(self, payload: Dict) -> Optional[List[int]]:
|
def _extract_target_model_ids(self, payload: Dict[str, Any]) -> Optional[List[int]]:
|
||||||
if not isinstance(payload, Mapping):
|
if not isinstance(payload, Mapping):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -2787,7 +2887,7 @@ class ModelUpdateHandler:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
to_dict = getattr(metadata, "to_dict", None)
|
to_dict = getattr(metadata, "to_dict", None)
|
||||||
if callable(to_dict):
|
if to_dict:
|
||||||
try:
|
try:
|
||||||
return to_dict()
|
return to_dict()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2798,7 +2898,7 @@ class ModelUpdateHandler:
|
|||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def _read_json(self, request: web.Request) -> Dict:
|
async def _read_json(self, request: web.Request) -> Dict[str, Any]:
|
||||||
if not request.can_read_body:
|
if not request.can_read_body:
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
@@ -2830,7 +2930,7 @@ class ModelUpdateHandler:
|
|||||||
record,
|
record,
|
||||||
*,
|
*,
|
||||||
version_context: Optional[Dict[int, Dict[str, Any]]] = None,
|
version_context: Optional[Dict[int, Dict[str, Any]]] = None,
|
||||||
) -> Dict:
|
) -> Dict[str, Any]:
|
||||||
context = version_context or {}
|
context = version_context or {}
|
||||||
# Check user setting for hiding early access versions
|
# Check user setting for hiding early access versions
|
||||||
hide_early_access = False
|
hide_early_access = False
|
||||||
@@ -2859,7 +2959,7 @@ class ModelUpdateHandler:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_version(
|
def _serialize_version(
|
||||||
version, context: Optional[Dict[str, Any]]
|
version, context: Optional[Dict[str, Any]]
|
||||||
) -> Dict:
|
) -> Dict[str, Any]:
|
||||||
context = context or {}
|
context = context or {}
|
||||||
preview_override = context.get("preview_override")
|
preview_override = context.get("preview_override")
|
||||||
preview_url = (
|
preview_url = (
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Handler for the pending-delete undo endpoint.
|
||||||
|
|
||||||
|
Restores a staged delete batch (models or recipes) via
|
||||||
|
``PendingDeleteService.undo`` and then repairs the affected library caches:
|
||||||
|
the model cache entry is restored from the manifest's ``model_snapshot``
|
||||||
|
(including the version index and hash index), tag counts are re-incremented,
|
||||||
|
and the recipe cache is re-populated via ``RecipeScanner.add_recipe``.
|
||||||
|
|
||||||
|
The per-type scanner is resolved from the manifest's ``model_type`` page value
|
||||||
|
through the SAME ServiceRegistry getters the model route registrars use
|
||||||
|
(lora/checkpoint/embedding) - never a hardcoded lora scanner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, cast
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...services.pending_delete_service import get_pending_delete_service
|
||||||
|
from .model_handlers import _broadcast_models_changed
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Manifest ``model_type`` page values -> ServiceRegistry scanner getter names.
|
||||||
|
# The model route registrars resolve per-type scanners via these getters
|
||||||
|
# (lora_routes / checkpoint_routes / embedding_routes); undo must do the same
|
||||||
|
# so the CORRECT cache is restored for the deleted model's type.
|
||||||
|
_MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
||||||
|
"loras": "get_lora_scanner",
|
||||||
|
"checkpoints": "get_checkpoint_scanner",
|
||||||
|
"embeddings": "get_embedding_scanner",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
||||||
|
# joined into filesystem paths by ``_find_batch_dir``, so reject anything that
|
||||||
|
# does not match this exact shape (blocks path-traversal via batch_id).
|
||||||
|
_BATCH_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeleteHandler:
|
||||||
|
"""Handle undo requests for staged model/recipe deletions."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
service_factory: Callable[[], Awaitable[Any]] = get_pending_delete_service,
|
||||||
|
scanner_getter: Optional[Callable[[str], Awaitable[Any]]] = None,
|
||||||
|
recipe_scanner_getter: Optional[Callable[[], Awaitable[Any]]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._service_factory: Callable[[], Awaitable[Any]] = service_factory
|
||||||
|
self._scanner_getter: Callable[[str], Awaitable[Any]] = (
|
||||||
|
scanner_getter or self._resolve_scanner
|
||||||
|
)
|
||||||
|
self._recipe_scanner_getter: Callable[[], Awaitable[Any]] = (
|
||||||
|
recipe_scanner_getter or self._resolve_recipe_scanner
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_scanner(model_type: str) -> Any:
|
||||||
|
"""Resolve the per-type scanner for a manifest ``model_type``.
|
||||||
|
|
||||||
|
The getter is looked up on the ServiceRegistry module namespace at call
|
||||||
|
time so tests (and the registry stubs) can patch it.
|
||||||
|
"""
|
||||||
|
from ...services import service_registry
|
||||||
|
|
||||||
|
getter_name = _MODEL_TYPE_GETTER_NAMES.get(model_type)
|
||||||
|
if getter_name is None:
|
||||||
|
raise ValueError(f"Unknown model type: {model_type}")
|
||||||
|
getter = getattr(service_registry.ServiceRegistry, getter_name, None)
|
||||||
|
if not callable(getter):
|
||||||
|
raise ValueError(f"No scanner getter for model type: {model_type}")
|
||||||
|
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
|
||||||
|
if scanner is None:
|
||||||
|
raise ValueError(f"No scanner registered for model type: {model_type}")
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_recipe_scanner() -> Any:
|
||||||
|
"""Resolve the recipe scanner via the ServiceRegistry module namespace."""
|
||||||
|
from ...services import service_registry
|
||||||
|
|
||||||
|
getter = getattr(service_registry.ServiceRegistry, "get_recipe_scanner", None)
|
||||||
|
if not callable(getter):
|
||||||
|
raise ValueError("Recipe scanner getter unavailable")
|
||||||
|
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
|
||||||
|
if scanner is None:
|
||||||
|
raise ValueError("No recipe scanner registered")
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
async def undo_delete(self, request: web.Request) -> web.Response:
|
||||||
|
"""Restore a staged batch and its library cache entry.
|
||||||
|
|
||||||
|
Body: ``{"batch_id": str}``. On success returns
|
||||||
|
``{"success": True, "restored": [<original paths>], "kind": kind}``.
|
||||||
|
Expired/unknown batches and occupied target paths -> 404.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||||
|
)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||||
|
)
|
||||||
|
batch_id = data.get("batch_id")
|
||||||
|
if not batch_id or not isinstance(batch_id, str):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "batch_id is required"}, status=400
|
||||||
|
)
|
||||||
|
if not _BATCH_ID_RE.fullmatch(batch_id):
|
||||||
|
# batch_id is joined into a path by _find_batch_dir - restrict to
|
||||||
|
# the exact staged-id shape so traversal payloads get 400.
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid batch_id"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
service = await self._service_factory()
|
||||||
|
try:
|
||||||
|
# Read the manifest BEFORE undo: undo() removes the batch dir.
|
||||||
|
manifest = await self._read_staged_manifest(service, batch_id)
|
||||||
|
result = await service.undo(batch_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error undoing batch %s: %s", batch_id, exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
kind = result.get("kind")
|
||||||
|
try:
|
||||||
|
if kind == "model":
|
||||||
|
if manifest is not None:
|
||||||
|
await self._restore_model_cache(manifest)
|
||||||
|
else:
|
||||||
|
# undo() raises when the manifest is missing, so this only
|
||||||
|
# happens defensively - files are restored regardless.
|
||||||
|
logger.warning(
|
||||||
|
"Manifest missing after undo of %s; skipping cache restore",
|
||||||
|
batch_id,
|
||||||
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
|
elif kind == "recipe":
|
||||||
|
# Recipe undo is client-refresh only: re-add to the scanner
|
||||||
|
# cache, no models_changed broadcast.
|
||||||
|
if manifest is not None:
|
||||||
|
await self._restore_recipe_cache(result, manifest)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Manifest missing after undo of %s; skipping cache restore",
|
||||||
|
batch_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Files are already restored; only the cache restoration failed.
|
||||||
|
logger.error(
|
||||||
|
"Cache restoration failed after undo of %s: %s",
|
||||||
|
batch_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"restored": result.get("restored", []),
|
||||||
|
"kind": kind,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _read_staged_manifest(
|
||||||
|
service: Any, batch_id: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Locate and read the batch manifest while it still exists on disk."""
|
||||||
|
batch_dir = await service._find_batch_dir(batch_id)
|
||||||
|
if not batch_dir:
|
||||||
|
return None
|
||||||
|
manifest_path = os.path.join(batch_dir, "manifest.json")
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
logger.debug("Failed to read manifest for batch %s: %s", batch_id, exc)
|
||||||
|
return None
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
async def _restore_model_cache(self, manifest: Dict[str, Any]) -> None:
|
||||||
|
"""Re-add every deleted model's cache entry from the manifest.
|
||||||
|
|
||||||
|
Each main-file entry carries the deleted model's ``snapshot`` (added at
|
||||||
|
stage time), so a merged bulk manifest holds ALL snapshots - undo must
|
||||||
|
restore every one, not just the top-level winner's. Old-format
|
||||||
|
manifests without entry snapshots fall back to the top-level
|
||||||
|
``model_snapshot`` (backward compat / single-delete path).
|
||||||
|
"""
|
||||||
|
model_type = manifest.get("model_type")
|
||||||
|
if not model_type or not isinstance(model_type, str):
|
||||||
|
raise ValueError(f"Manifest carries no model_type: {manifest.get('batch_id')}")
|
||||||
|
scanner = await self._scanner_getter(model_type)
|
||||||
|
|
||||||
|
# Collect one snapshot per distinct file_path from the entry snapshots.
|
||||||
|
snapshots: List[Dict[str, Any]] = []
|
||||||
|
seen: Set[str] = set()
|
||||||
|
for entry in manifest.get("entries") or []:
|
||||||
|
snapshot = entry.get("snapshot")
|
||||||
|
if not isinstance(snapshot, dict):
|
||||||
|
continue
|
||||||
|
file_path = snapshot.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
continue
|
||||||
|
if file_path in seen:
|
||||||
|
continue
|
||||||
|
seen.add(file_path)
|
||||||
|
snapshots.append(snapshot)
|
||||||
|
|
||||||
|
if not snapshots:
|
||||||
|
# Backward compat: pre-F3 manifests carry only the top-level
|
||||||
|
# model_snapshot (single-delete path, unchanged behavior).
|
||||||
|
top = manifest.get("model_snapshot")
|
||||||
|
if isinstance(top, dict) and top.get("file_path"):
|
||||||
|
snapshots = [top]
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Manifest %s has no restorable model snapshot; skipping cache restore",
|
||||||
|
manifest.get("batch_id"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
if cache is None:
|
||||||
|
logger.warning(
|
||||||
|
"Scanner cache unavailable for %s; skipping cache restore", model_type
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for snapshot in snapshots:
|
||||||
|
file_path = str(snapshot["file_path"])
|
||||||
|
# A rescan between delete and undo may have re-added a stale entry
|
||||||
|
# for this path - drop it so exactly one (the snapshot) remains.
|
||||||
|
cache.raw_data = [
|
||||||
|
item for item in cache.raw_data if item.get("file_path") != file_path
|
||||||
|
]
|
||||||
|
|
||||||
|
# Restore tag counts (mirror of the bulk-delete decrement in
|
||||||
|
# _batch_update_cache_for_deleted_models: undo re-increments).
|
||||||
|
tags = snapshot.get("tags")
|
||||||
|
if isinstance(tags, list):
|
||||||
|
for tag in tags:
|
||||||
|
if not isinstance(tag, str) or not tag:
|
||||||
|
continue
|
||||||
|
scanner._tags_count[tag] = scanner._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
|
cache.raw_data.append(dict(snapshot))
|
||||||
|
|
||||||
|
# Re-register the path in the hash index (add_entry guards a
|
||||||
|
# missing sha256 internally; still guard defensively here).
|
||||||
|
sha256 = snapshot.get("sha256") or ""
|
||||||
|
autov3 = snapshot.get("autov3")
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is not None and sha256 and file_path:
|
||||||
|
hash_index.add_entry(sha256, file_path, autov3)
|
||||||
|
|
||||||
|
# Follow the bulk-delete cache-update pattern ONCE after all entries,
|
||||||
|
# including the explicit version-index rebuild so the version index
|
||||||
|
# does not go stale.
|
||||||
|
cache.rebuild_version_index()
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
scanner.bump_cache_version()
|
||||||
|
|
||||||
|
persist = getattr(scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist):
|
||||||
|
result = persist()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
async def _restore_recipe_cache(
|
||||||
|
self, result: Dict[str, Any], manifest: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Re-add a restored recipe via ``RecipeScanner.add_recipe``.
|
||||||
|
|
||||||
|
The recipe JSON embeds the full recipe_data (incl. id/file_path);
|
||||||
|
``add_recipe`` only READS the ``_json_path_map`` so the forced frontend
|
||||||
|
refresh self-heals any transient path-map gap.
|
||||||
|
"""
|
||||||
|
restored = result.get("restored") or []
|
||||||
|
json_path = next(
|
||||||
|
(p for p in restored if isinstance(p, str) and p.endswith(".json")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not json_path or not os.path.exists(json_path):
|
||||||
|
# Defensive fallback to the manifest's recipe_snapshot file_path.
|
||||||
|
snapshot = manifest.get("recipe_snapshot") or {}
|
||||||
|
fallback = snapshot.get("file_path")
|
||||||
|
if fallback and os.path.exists(fallback):
|
||||||
|
json_path = fallback
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Restored recipe JSON not found in %s; skipping cache restore",
|
||||||
|
restored,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(json_path, "r", encoding="utf-8") as handle:
|
||||||
|
recipe_data = json.load(handle)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
logger.warning("Failed to load restored recipe JSON %s: %s", json_path, exc)
|
||||||
|
return
|
||||||
|
if not isinstance(recipe_data, dict):
|
||||||
|
return
|
||||||
|
recipe_scanner = await self._recipe_scanner_getter()
|
||||||
|
await recipe_scanner.add_recipe(recipe_data)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PendingDeleteHandler"]
|
||||||
@@ -10,7 +10,7 @@ import asyncio
|
|||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
|||||||
RecipeScannerGetter = Callable[[], Any]
|
RecipeScannerGetter = Callable[[], Any]
|
||||||
CivitaiClientGetter = Callable[[], Any]
|
CivitaiClientGetter = Callable[[], Any]
|
||||||
|
|
||||||
|
# Cap concurrent preview-dimension reads across requests. With a cold LRU
|
||||||
|
# cache one page can touch up to page_size image files; 16 balances SSD and
|
||||||
|
# HDD throughput without starving the event loop.
|
||||||
|
_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Read preview dimensions off the event loop under the concurrency cap.
|
||||||
|
|
||||||
|
PIL I/O runs in a worker thread so it never blocks the event loop, and the
|
||||||
|
semaphore bounds how many files are opened at once even when many list
|
||||||
|
requests land together.
|
||||||
|
"""
|
||||||
|
async with _DIMS_READ_SEMAPHORE:
|
||||||
|
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RecipeHandlerSet:
|
class RecipeHandlerSet:
|
||||||
@@ -96,6 +112,11 @@ class RecipeHandlerSet:
|
|||||||
"repair_recipe": self.management.repair_recipe,
|
"repair_recipe": self.management.repair_recipe,
|
||||||
"repair_recipes_bulk": self.management.repair_recipes_bulk,
|
"repair_recipes_bulk": self.management.repair_recipes_bulk,
|
||||||
"get_repair_progress": self.management.get_repair_progress,
|
"get_repair_progress": self.management.get_repair_progress,
|
||||||
|
"rematch_recipes": self.management.rematch_recipes,
|
||||||
|
"cancel_rematch": self.management.cancel_rematch,
|
||||||
|
"rematch_recipe": self.management.rematch_recipe,
|
||||||
|
"rematch_recipes_bulk": self.management.rematch_recipes_bulk,
|
||||||
|
"get_rematch_progress": self.management.get_rematch_progress,
|
||||||
"start_batch_import": self.batch_import.start_batch_import,
|
"start_batch_import": self.batch_import.start_batch_import,
|
||||||
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
||||||
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
||||||
@@ -246,7 +267,8 @@ class RecipeListingHandler:
|
|||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
items = result.get("items", [])
|
||||||
|
for item in items:
|
||||||
file_path = item.get("file_path")
|
file_path = item.get("file_path")
|
||||||
if file_path:
|
if file_path:
|
||||||
item["file_url"] = self.format_recipe_file_url(file_path)
|
item["file_url"] = self.format_recipe_file_url(file_path)
|
||||||
@@ -255,6 +277,26 @@ class RecipeListingHandler:
|
|||||||
item.setdefault("loras", [])
|
item.setdefault("loras", [])
|
||||||
item.setdefault("base_model", "")
|
item.setdefault("base_model", "")
|
||||||
|
|
||||||
|
# Batch preview dimension reads with asyncio.gather. The previous
|
||||||
|
# loop awaited asyncio.to_thread once per item, so a page_size=100
|
||||||
|
# request submitted 100 sequential thread calls (50-300ms cold-page
|
||||||
|
# latency). gather runs them concurrently while the semaphore caps
|
||||||
|
# disk opens; dimensions stay omitted (not null) when a preview has
|
||||||
|
# no readable size (video, missing file).
|
||||||
|
to_read = [
|
||||||
|
(i, item.get("file_path"))
|
||||||
|
for i, item in enumerate(items)
|
||||||
|
if item.get("file_path")
|
||||||
|
]
|
||||||
|
if to_read:
|
||||||
|
dims_list = await asyncio.gather(
|
||||||
|
*(_read_preview_dims(path) for _, path in to_read)
|
||||||
|
)
|
||||||
|
for (idx, _), dims in zip(to_read, dims_list):
|
||||||
|
if dims:
|
||||||
|
item = items[idx]
|
||||||
|
item["width"], item["height"] = dims
|
||||||
|
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
|
||||||
@@ -538,7 +580,12 @@ class RecipeQueryHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes()
|
include_prompt = (
|
||||||
|
request.query.get("include_prompt", "false").lower() in ("1", "true")
|
||||||
|
)
|
||||||
|
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes(
|
||||||
|
include_prompt=include_prompt
|
||||||
|
)
|
||||||
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
|
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
|
||||||
response_data = []
|
response_data = []
|
||||||
|
|
||||||
@@ -571,6 +618,7 @@ class RecipeQueryHandler:
|
|||||||
response_data.append(
|
response_data.append(
|
||||||
{
|
{
|
||||||
"type": "fingerprint",
|
"type": "fingerprint",
|
||||||
|
"key": f"g-{len(response_data) + 1}",
|
||||||
"fingerprint": fingerprint,
|
"fingerprint": fingerprint,
|
||||||
"count": len(recipes),
|
"count": len(recipes),
|
||||||
"recipes": recipes,
|
"recipes": recipes,
|
||||||
@@ -606,6 +654,7 @@ class RecipeQueryHandler:
|
|||||||
response_data.append(
|
response_data.append(
|
||||||
{
|
{
|
||||||
"type": "source_path",
|
"type": "source_path",
|
||||||
|
"key": f"g-{len(response_data) + 1}",
|
||||||
"fingerprint": url,
|
"fingerprint": url,
|
||||||
"count": len(recipes),
|
"count": len(recipes),
|
||||||
"recipes": recipes,
|
"recipes": recipes,
|
||||||
@@ -850,6 +899,159 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def rematch_recipes(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe scanner unavailable"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mutual exclusion: a global rematch cannot start while a rematch
|
||||||
|
# OR a repair is already running — both mutate recipes under the
|
||||||
|
# same mutation lock.
|
||||||
|
if (
|
||||||
|
self._ws_manager.is_recipe_rematch_running()
|
||||||
|
or self._ws_manager.is_recipe_repair_running()
|
||||||
|
):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe rematch already in progress"},
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_scanner.reset_cancellation()
|
||||||
|
|
||||||
|
async def progress_callback(data):
|
||||||
|
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
||||||
|
|
||||||
|
# Run in background to avoid timeout
|
||||||
|
async def run_rematch():
|
||||||
|
try:
|
||||||
|
await recipe_scanner.rematch_all_recipes(
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(
|
||||||
|
f"Error in recipe rematch task: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
await self._ws_manager.broadcast_recipe_rematch_progress(
|
||||||
|
{"status": "error", "error": str(e)}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Keep the final status for a while so the UI can see it
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
self._ws_manager.cleanup_recipe_rematch_progress()
|
||||||
|
|
||||||
|
asyncio.create_task(run_rematch())
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "message": "Recipe rematch started"}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error starting recipe rematch: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def cancel_rematch(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe scanner unavailable"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_scanner.cancel_task()
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "message": "Cancellation requested"}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error cancelling recipe rematch: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def rematch_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||||
|
"""Rematch deleted resources for multiple recipes by their IDs.
|
||||||
|
|
||||||
|
Accepts a JSON body with a "recipe_ids" array. The per-recipe loop is
|
||||||
|
delegated to the scanner's rematch_recipes_bulk; this handler only
|
||||||
|
parses the request and returns the scanner's summary.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe scanner unavailable"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A bulk rematch must not queue behind a running global rematch's
|
||||||
|
# mutation lock.
|
||||||
|
if self._ws_manager.is_recipe_rematch_running():
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe rematch already in progress"},
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_ids = data.get("recipe_ids", [])
|
||||||
|
if not recipe_ids:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_ids are required"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
|
||||||
|
return web.json_response(result)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error performing bulk rematch: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(exc)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
async def rematch_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe scanner unavailable"},
|
||||||
|
status=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reject per-recipe rematches while a global run is in progress so
|
||||||
|
# they do not queue behind the mutation lock.
|
||||||
|
if self._ws_manager.is_recipe_rematch_running():
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Recipe rematch already in progress"},
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
|
||||||
|
return web.json_response(result)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error rematching single recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_rematch_progress(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
progress = self._ws_manager.get_recipe_rematch_progress()
|
||||||
|
if progress:
|
||||||
|
return web.json_response({"success": True, "progress": progress})
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "message": "No rematch in progress"}, status=404
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error getting rematch progress: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def reimport_recipe(self, request: web.Request) -> web.Response:
|
async def reimport_recipe(self, request: web.Request) -> web.Response:
|
||||||
"""Delete a recipe and re-import it from its source URL.
|
"""Delete a recipe and re-import it from its source URL.
|
||||||
|
|
||||||
@@ -1045,10 +1247,10 @@ class RecipeManagementHandler:
|
|||||||
*,
|
*,
|
||||||
image_url: str,
|
image_url: str,
|
||||||
name: str,
|
name: str,
|
||||||
lora_entries: list,
|
lora_entries: list[Any],
|
||||||
checkpoint_entry: dict,
|
checkpoint_entry: Dict[str, Any] | None,
|
||||||
gen_params_request: dict,
|
gen_params_request: Dict[str, Any] | None,
|
||||||
tags: list,
|
tags: list[Any],
|
||||||
base_model: str,
|
base_model: str,
|
||||||
source_path: str,
|
source_path: str,
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
@@ -1081,6 +1283,12 @@ class RecipeManagementHandler:
|
|||||||
_original_image_url,
|
_original_image_url,
|
||||||
) = await self._download_remote_media(image_url)
|
) = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# Build a version-cached map of local model hashes to cache items so
|
||||||
|
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||||
|
# exist on disk. Built once and shared by every parse pass below.
|
||||||
|
local_cache = await recipe_scanner.build_local_hash_cache()
|
||||||
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
|
|
||||||
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
# Extract embedded EXIF metadata (offloaded to thread pool in this call)
|
||||||
embedded_gen_params = {}
|
embedded_gen_params = {}
|
||||||
parsed_embedded = None
|
parsed_embedded = None
|
||||||
@@ -1102,9 +1310,16 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
parsed_embedded = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
raw_embedded, recipe_scanner=recipe_scanner
|
parsed_embedded = await parser.parse_metadata(
|
||||||
)
|
raw_embedded,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
embedded_gen_params = parsed_embedded["gen_params"]
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
else:
|
else:
|
||||||
@@ -1135,9 +1350,16 @@ class RecipeManagementHandler:
|
|||||||
civitai_inner_meta
|
civitai_inner_meta
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
civitai_parsed = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
civitai_inner_meta, recipe_scanner=recipe_scanner
|
civitai_parsed = await parser.parse_metadata(
|
||||||
)
|
civitai_inner_meta,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if civitai_parsed and "gen_params" in civitai_parsed:
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
# Merge: API gen_params override EXIF at field level,
|
# Merge: API gen_params override EXIF at field level,
|
||||||
# EXIF fills in fields the API doesn't have.
|
# EXIF fills in fields the API doesn't have.
|
||||||
@@ -1641,7 +1863,7 @@ class RecipeManagementHandler:
|
|||||||
if not provider:
|
if not provider:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
version_info = await provider.get_model_version_info(version_id)
|
version_info = await provider.get_model_version_info(str(version_id))
|
||||||
if isinstance(version_info, tuple):
|
if isinstance(version_info, tuple):
|
||||||
version_info = version_info[0]
|
version_info = version_info[0]
|
||||||
|
|
||||||
@@ -1761,6 +1983,12 @@ class RecipeManagementHandler:
|
|||||||
await self._download_remote_media(image_url)
|
await self._download_remote_media(image_url)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Build a version-cached map of local model hashes to cache items so
|
||||||
|
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||||
|
# exist on disk. Built once and shared by every parse pass below.
|
||||||
|
local_cache = await recipe_scanner.build_local_hash_cache()
|
||||||
|
from ...recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||||
|
|
||||||
# Extract embedded EXIF metadata
|
# Extract embedded EXIF metadata
|
||||||
embedded_gen_params = {}
|
embedded_gen_params = {}
|
||||||
parsed_embedded = None
|
parsed_embedded = None
|
||||||
@@ -1782,9 +2010,16 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
parsed_embedded = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
raw_embedded, recipe_scanner=recipe_scanner
|
parsed_embedded = await parser.parse_metadata(
|
||||||
)
|
raw_embedded,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_embedded, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||||
embedded_gen_params = parsed_embedded["gen_params"]
|
embedded_gen_params = parsed_embedded["gen_params"]
|
||||||
finally:
|
finally:
|
||||||
@@ -1822,9 +2057,16 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
parsed_embedded = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
raw_orig, recipe_scanner=recipe_scanner
|
parsed_embedded = await parser.parse_metadata(
|
||||||
)
|
raw_orig,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parsed_embedded = await parser.parse_metadata(
|
||||||
|
raw_orig, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
parsed_embedded
|
parsed_embedded
|
||||||
and "gen_params" in parsed_embedded
|
and "gen_params" in parsed_embedded
|
||||||
@@ -1858,9 +2100,16 @@ class RecipeManagementHandler:
|
|||||||
civitai_inner_meta
|
civitai_inner_meta
|
||||||
)
|
)
|
||||||
if parser:
|
if parser:
|
||||||
civitai_parsed = await parser.parse_metadata(
|
if isinstance(parser, CivitaiApiMetadataParser):
|
||||||
civitai_inner_meta, recipe_scanner=recipe_scanner
|
civitai_parsed = await parser.parse_metadata(
|
||||||
)
|
civitai_inner_meta,
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
local_cache=local_cache,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
civitai_parsed = await parser.parse_metadata(
|
||||||
|
civitai_inner_meta, recipe_scanner=recipe_scanner
|
||||||
|
)
|
||||||
if civitai_parsed and "gen_params" in civitai_parsed:
|
if civitai_parsed and "gen_params" in civitai_parsed:
|
||||||
# Merge: API gen_params override EXIF at field level,
|
# Merge: API gen_params override EXIF at field level,
|
||||||
# EXIF fills in fields the API doesn't have.
|
# EXIF fills in fields the API doesn't have.
|
||||||
@@ -2072,33 +2321,44 @@ class RecipeManagementHandler:
|
|||||||
parsed_input = {**image_data, **inner_meta}
|
parsed_input = {**image_data, **inner_meta}
|
||||||
parsed_input.pop("meta", None)
|
parsed_input.pop("meta", None)
|
||||||
|
|
||||||
# Build a local cache of {hash → cache_item} so the parser can
|
# Build the shared local hash cache so the parser can skip CivitAI
|
||||||
# skip CivitAI API calls for models that exist on disk.
|
# API calls for models that exist on disk.
|
||||||
local_cache: Dict[str, Dict[str, Any]] = {}
|
local_cache: Dict[str, Dict[str, Any]] = (
|
||||||
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
await recipe_scanner.build_local_hash_cache()
|
||||||
if lora_scanner and model_hash:
|
)
|
||||||
try:
|
|
||||||
parent_cache_data = await lora_scanner.get_cached_data()
|
# Bounded supplement for un-backfilled parents. The shared builder
|
||||||
for item in getattr(parent_cache_data, "raw_data", []):
|
# never computes autov3; when the parent model exists on disk but
|
||||||
if item.get("sha256", "").lower() == model_hash.lower():
|
# its cached entry has no stored AutoV3, compute it for that single
|
||||||
local_cache[model_hash.lower()] = item
|
# file and register the AutoV3 key so the parser can also match on
|
||||||
# Compute AutoV3 so the parser can also match on
|
# that hash type (CivitAI metadata resources use AutoV3). This runs
|
||||||
# that hash type (CivitAI metadata resources use
|
# whenever the parent is found with an empty autov3, independent of
|
||||||
# AutoV3).
|
# whether the sha256 key is already present in the shared cache.
|
||||||
file_path = item.get("file_path")
|
if model_hash:
|
||||||
if file_path and os.path.exists(file_path):
|
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
|
||||||
try:
|
if lora_scanner:
|
||||||
from ...utils.file_utils import (
|
try:
|
||||||
calculate_autov3,
|
parent_cache_data = await lora_scanner.get_cached_data()
|
||||||
)
|
for item in getattr(parent_cache_data, "raw_data", []):
|
||||||
autov3 = calculate_autov3(file_path)
|
if item.get("sha256", "").lower() == model_hash.lower():
|
||||||
if autov3:
|
autov3 = (item.get("autov3") or "").lower()
|
||||||
local_cache[autov3.lower()] = item
|
if not autov3:
|
||||||
except Exception:
|
file_path = item.get("file_path")
|
||||||
pass
|
if file_path and os.path.exists(file_path):
|
||||||
break
|
try:
|
||||||
except Exception:
|
from ...utils.file_utils import (
|
||||||
pass
|
calculate_autov3,
|
||||||
|
)
|
||||||
|
autov3 = (
|
||||||
|
calculate_autov3(file_path) or ""
|
||||||
|
).lower()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if autov3:
|
||||||
|
local_cache[autov3] = item
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
parser = self._analysis_service._recipe_parser_factory.create_parser(
|
||||||
parsed_input
|
parsed_input
|
||||||
@@ -2130,10 +2390,10 @@ class RecipeManagementHandler:
|
|||||||
parent_model_id: int | None = None
|
parent_model_id: int | None = None
|
||||||
parent_version_name: str | None = None
|
parent_version_name: str | None = None
|
||||||
parent_model_name: str | None = None
|
parent_model_name: str | None = None
|
||||||
# Prefer sha256 key; fall back to any cached entry.
|
# Resolve the parent strictly by its sha256 key. There is no
|
||||||
|
# arbitrary fallback: with a full-library cache, picking any entry
|
||||||
|
# would corrupt the isDeleted reconciliation below.
|
||||||
parent_item = local_cache.get(model_hash.lower()) if model_hash else None
|
parent_item = local_cache.get(model_hash.lower()) if model_hash else None
|
||||||
if parent_item is None and local_cache:
|
|
||||||
parent_item = next(iter(local_cache.values()))
|
|
||||||
if parent_item:
|
if parent_item:
|
||||||
civ = parent_item.get("civitai") or {}
|
civ = parent_item.get("civitai") or {}
|
||||||
if isinstance(civ, dict):
|
if isinstance(civ, dict):
|
||||||
@@ -2349,7 +2609,7 @@ class RecipeAnalysisHandler:
|
|||||||
content_type = request.headers.get("Content-Type", "")
|
content_type = request.headers.get("Content-Type", "")
|
||||||
if "multipart/form-data" in content_type:
|
if "multipart/form-data" in content_type:
|
||||||
reader = await request.multipart()
|
reader = await request.multipart()
|
||||||
field = await reader.next()
|
field: Any = await reader.next()
|
||||||
if field is None or field.name != "image":
|
if field is None or field.name != "image":
|
||||||
raise RecipeValidationError("No image field found")
|
raise RecipeValidationError("No image field found")
|
||||||
image_chunks = bytearray()
|
image_chunks = bytearray()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from typing import Dict
|
from typing import Any, Dict
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
from .base_model_routes import BaseModelRoutes
|
from .base_model_routes import BaseModelRoutes
|
||||||
from .model_route_registrar import ModelRouteRegistrar
|
from .model_route_registrar import ModelRouteRegistrar
|
||||||
@@ -31,13 +31,13 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
# Attach service dependencies
|
# Attach service dependencies
|
||||||
self.attach_service(self.service)
|
self.attach_service(self.service)
|
||||||
|
|
||||||
def setup_routes(self, app: web.Application):
|
def setup_routes(self, app: web.Application, prefix: str = "loras"):
|
||||||
"""Setup LoRA routes"""
|
"""Setup LoRA routes"""
|
||||||
# Schedule service initialization on app startup
|
# Schedule service initialization on app startup
|
||||||
app.on_startup.append(lambda _: self.initialize_services())
|
app.on_startup.append(lambda _: self.initialize_services())
|
||||||
|
|
||||||
# Setup common routes with 'loras' prefix (includes page route)
|
# Setup common routes with 'loras' prefix (includes page route)
|
||||||
super().setup_routes(app, "loras")
|
super().setup_routes(app, prefix)
|
||||||
|
|
||||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
"""Setup LoRA-specific routes"""
|
"""Setup LoRA-specific routes"""
|
||||||
@@ -73,7 +73,7 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
|
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
|
||||||
)
|
)
|
||||||
|
|
||||||
def _parse_specific_params(self, request: web.Request) -> Dict:
|
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
|
||||||
"""Parse LoRA-specific parameters"""
|
"""Parse LoRA-specific parameters"""
|
||||||
params = {}
|
params = {}
|
||||||
|
|
||||||
@@ -119,25 +119,6 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
logger.error(f"Error getting letter counts: {e}")
|
logger.error(f"Error getting letter counts: {e}")
|
||||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
async def get_lora_notes(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get notes for a specific LoRA file"""
|
|
||||||
try:
|
|
||||||
lora_name = request.query.get("name")
|
|
||||||
if not lora_name:
|
|
||||||
return web.Response(text="Lora file name is required", status=400)
|
|
||||||
|
|
||||||
notes = await self.service.get_lora_notes(lora_name)
|
|
||||||
if notes is not None:
|
|
||||||
return web.json_response({"success": True, "notes": notes})
|
|
||||||
else:
|
|
||||||
return web.json_response(
|
|
||||||
{"success": False, "error": "LoRA not found in cache"}, status=404
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting lora notes: {e}", exc_info=True)
|
|
||||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
|
||||||
|
|
||||||
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
|
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
|
||||||
"""Get trigger words for a specific LoRA file"""
|
"""Get trigger words for a specific LoRA file"""
|
||||||
try:
|
try:
|
||||||
@@ -168,52 +149,6 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
|
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get the static preview URL for a LoRA file"""
|
|
||||||
try:
|
|
||||||
lora_name = request.query.get("name")
|
|
||||||
if not lora_name:
|
|
||||||
return web.Response(text="Lora file name is required", status=400)
|
|
||||||
|
|
||||||
preview_url = await self.service.get_lora_preview_url(lora_name)
|
|
||||||
if preview_url:
|
|
||||||
return web.json_response({"success": True, "preview_url": preview_url})
|
|
||||||
else:
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"error": "No preview URL found for the specified lora",
|
|
||||||
},
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
|
|
||||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
|
||||||
|
|
||||||
async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
|
|
||||||
"""Get the Civitai URL for a LoRA file"""
|
|
||||||
try:
|
|
||||||
lora_name = request.query.get("name")
|
|
||||||
if not lora_name:
|
|
||||||
return web.Response(text="Lora file name is required", status=400)
|
|
||||||
|
|
||||||
result = await self.service.get_lora_civitai_url(lora_name)
|
|
||||||
if result["civitai_url"]:
|
|
||||||
return web.json_response({"success": True, **result})
|
|
||||||
else:
|
|
||||||
return web.json_response(
|
|
||||||
{
|
|
||||||
"success": False,
|
|
||||||
"error": "No Civitai data found for the specified lora",
|
|
||||||
},
|
|
||||||
status=404,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
|
|
||||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
|
||||||
|
|
||||||
async def get_random_loras(self, request: web.Request) -> web.Response:
|
async def get_random_loras(self, request: web.Request) -> web.Response:
|
||||||
"""Get random LoRAs based on filters and strength ranges"""
|
"""Get random LoRAs based on filters and strength ranges"""
|
||||||
try:
|
try:
|
||||||
@@ -337,7 +272,7 @@ class LoraRoutes(BaseModelRoutes):
|
|||||||
graph_identifier = entry.get("graph_id")
|
graph_identifier = entry.get("graph_id")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed_node_id = int(node_identifier)
|
parsed_node_id = int(node_identifier) # pyright: ignore[reportArgumentType]
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
parsed_node_id = node_identifier
|
parsed_node_id = node_identifier
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ miscellaneous endpoints share a consistent registration flow.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Iterable, Mapping
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ class MiscRouteRegistrar:
|
|||||||
handler_lookup[definition.handler_name],
|
handler_lookup[definition.handler_name],
|
||||||
)
|
)
|
||||||
|
|
||||||
def _bind(self, method: str, path: str, handler: Callable) -> None:
|
def _bind(self, method: str, path: str, handler: Callable[..., Any]) -> None:
|
||||||
add_method_name = self._METHOD_MAP[method.upper()]
|
add_method_name = self._METHOD_MAP[method.upper()]
|
||||||
add_method = getattr(self._app.router, add_method_name)
|
add_method = getattr(self._app.router, add_method_name)
|
||||||
add_method(path, handler)
|
add_method(path, handler)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
from typing import Awaitable, Callable, Mapping
|
from typing import Awaitable, Callable, Mapping
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
from ..services.metadata_service import (
|
from ..services.metadata_service import (
|
||||||
get_metadata_archive_manager,
|
get_metadata_archive_manager,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Iterable, Mapping
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -174,15 +174,15 @@ class ModelRouteRegistrar:
|
|||||||
handler_lookup[definition.handler_name],
|
handler_lookup[definition.handler_name],
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_route(self, method: str, path: str, handler: Callable) -> None:
|
def add_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
|
||||||
self._bind_route(method, path, handler)
|
self._bind_route(method, path, handler)
|
||||||
|
|
||||||
def add_prefixed_route(
|
def add_prefixed_route(
|
||||||
self, method: str, path_template: str, prefix: str, handler: Callable
|
self, method: str, path_template: str, prefix: str, handler: Callable[..., Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
self._bind_route(method, path_template.replace("{prefix}", prefix), handler)
|
self._bind_route(method, path_template.replace("{prefix}", prefix), handler)
|
||||||
|
|
||||||
def _bind_route(self, method: str, path: str, handler: Callable) -> None:
|
def _bind_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
|
||||||
add_method_name = self._METHOD_MAP[method.upper()]
|
add_method_name = self._METHOD_MAP[method.upper()]
|
||||||
add_method = getattr(self._app.router, add_method_name)
|
add_method = getattr(self._app.router, add_method_name)
|
||||||
add_method(path, handler)
|
add_method(path, handler)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Route controller for the pending-delete undo endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .handlers.pending_delete_handler import PendingDeleteHandler
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeleteRoutes:
|
||||||
|
"""Shared route controller mirroring MiscRoutes/UpdateRoutes.
|
||||||
|
|
||||||
|
Registered ONCE per mode (py/lora_manager.py, standalone.py); NEVER through
|
||||||
|
the per-model-type ModelRouteRegistrar, which is instantiated per model
|
||||||
|
type and would register this non-prefixed route three times.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setup_routes(app: web.Application) -> None:
|
||||||
|
"""Register the shared undo-delete endpoint."""
|
||||||
|
handler = PendingDeleteHandler()
|
||||||
|
_ = app.router.add_post("/api/lm/undo-delete", handler.undo_delete)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PendingDeleteRoutes"]
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Mapping
|
from typing import Any, Callable, Mapping
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -61,6 +61,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/cancel-rematch", "cancel_rematch"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
|
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
|
||||||
@@ -105,7 +110,7 @@ class RecipeRouteRegistrar:
|
|||||||
handler = handler_lookup[definition.handler_name]
|
handler = handler_lookup[definition.handler_name]
|
||||||
self._bind_route(definition.method, definition.path, handler)
|
self._bind_route(definition.method, definition.path, handler)
|
||||||
|
|
||||||
def _bind_route(self, method: str, path: str, handler: Callable) -> None:
|
def _bind_route(self, method: str, path: str, handler: Callable[..., Any]) -> None:
|
||||||
add_method_name = self._METHOD_MAP[method.upper()]
|
add_method_name = self._METHOD_MAP[method.upper()]
|
||||||
add_method = getattr(self._app.router, add_method_name)
|
add_method = getattr(self._app.router, add_method_name)
|
||||||
add_method(path, handler)
|
add_method(path, handler)
|
||||||
|
|||||||
+11
-10
@@ -40,10 +40,11 @@ class StatsRoutes:
|
|||||||
"""Route handlers for Statistics page and API endpoints"""
|
"""Route handlers for Statistics page and API endpoints"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lora_scanner = None
|
self.lora_scanner: Any = None
|
||||||
self.checkpoint_scanner = None
|
self.checkpoint_scanner: Any = None
|
||||||
self.embedding_scanner = None
|
self.embedding_scanner: Any = None
|
||||||
self.usage_stats = None
|
self.usage_stats: Any = None
|
||||||
|
self._i18n_filter_added = False
|
||||||
self.template_env = jinja2.Environment(
|
self.template_env = jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||||
autoescape=True
|
autoescape=True
|
||||||
@@ -95,9 +96,9 @@ class StatsRoutes:
|
|||||||
server_i18n.set_locale(user_language)
|
server_i18n.set_locale(user_language)
|
||||||
|
|
||||||
# 为模板环境添加i18n过滤器
|
# 为模板环境添加i18n过滤器
|
||||||
if not hasattr(self.template_env, '_i18n_filter_added'):
|
if not self._i18n_filter_added:
|
||||||
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
self.template_env.filters['t'] = server_i18n.create_template_filter()
|
||||||
self.template_env._i18n_filter_added = True
|
self._i18n_filter_added = True
|
||||||
|
|
||||||
template = self.template_env.get_template('statistics.html')
|
template = self.template_env.get_template('statistics.html')
|
||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
@@ -549,7 +550,7 @@ class StatsRoutes:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
def _count_unused_models(self, models: List[Dict], usage_data: Dict) -> int:
|
def _count_unused_models(self, models: List[Dict[str, Any]], usage_data: Dict[str, Any]) -> int:
|
||||||
"""Count models that have never been used"""
|
"""Count models that have never been used"""
|
||||||
used_hashes = set(usage_data.keys())
|
used_hashes = set(usage_data.keys())
|
||||||
unused_count = 0
|
unused_count = 0
|
||||||
@@ -560,7 +561,7 @@ class StatsRoutes:
|
|||||||
|
|
||||||
return unused_count
|
return unused_count
|
||||||
|
|
||||||
def _get_top_used_models(self, usage_data: Dict, model_map: Dict, limit: int) -> List[Dict]:
|
def _get_top_used_models(self, usage_data: Dict[str, Any], model_map: Dict[str, Any], limit: int) -> List[Dict[str, Any]]:
|
||||||
"""Get top used models with their metadata"""
|
"""Get top used models with their metadata"""
|
||||||
sorted_usage = sorted(usage_data.items(), key=lambda x: x[1].get('total', 0), reverse=True)
|
sorted_usage = sorted(usage_data.items(), key=lambda x: x[1].get('total', 0), reverse=True)
|
||||||
|
|
||||||
@@ -578,7 +579,7 @@ class StatsRoutes:
|
|||||||
|
|
||||||
return top_models
|
return top_models
|
||||||
|
|
||||||
def _get_usage_timeline(self, usage_data: Dict, days: int) -> List[Dict]:
|
def _get_usage_timeline(self, usage_data: Dict[str, Any], days: int) -> List[Dict[str, Any]]:
|
||||||
"""Get usage timeline for the past N days"""
|
"""Get usage timeline for the past N days"""
|
||||||
timeline = []
|
timeline = []
|
||||||
today = datetime.now()
|
today = datetime.now()
|
||||||
@@ -614,7 +615,7 @@ class StatsRoutes:
|
|||||||
|
|
||||||
return list(reversed(timeline)) # Oldest to newest
|
return list(reversed(timeline)) # Oldest to newest
|
||||||
|
|
||||||
def _format_size(self, size_bytes: int) -> str:
|
def _format_size(self, size_bytes: float) -> str:
|
||||||
"""Format file size in human readable format"""
|
"""Format file size in human readable format"""
|
||||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||||
if size_bytes < 1024.0:
|
if size_bytes < 1024.0:
|
||||||
|
|||||||
+16
-13
@@ -6,7 +6,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
import asyncio
|
import asyncio
|
||||||
from aiohttp import web, ClientError
|
from aiohttp import web, ClientError
|
||||||
from typing import Dict, List
|
from typing import Any, Dict, List, cast
|
||||||
|
|
||||||
from ..utils.settings_paths import ensure_settings_file
|
from ..utils.settings_paths import ensure_settings_file
|
||||||
from ..services.downloader import get_downloader
|
from ..services.downloader import get_downloader
|
||||||
@@ -467,9 +467,10 @@ class UpdateRoutes:
|
|||||||
if not success:
|
if not success:
|
||||||
logger.error(f"Failed to fetch release info: {data}")
|
logger.error(f"Failed to fetch release info: {data}")
|
||||||
return False, ""
|
return False, ""
|
||||||
|
|
||||||
zip_url = data.get("zipball_url")
|
release_payload = cast(dict[str, Any], data)
|
||||||
version = data.get("tag_name", "unknown")
|
zip_url = release_payload.get("zipball_url", "")
|
||||||
|
version = release_payload.get("tag_name", "unknown")
|
||||||
|
|
||||||
# Download ZIP to temporary file
|
# Download ZIP to temporary file
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
|
||||||
@@ -580,9 +581,10 @@ class UpdateRoutes:
|
|||||||
logger.warning("Failed to fetch GitHub commit: %s", data)
|
logger.warning("Failed to fetch GitHub commit: %s", data)
|
||||||
return "main", [], 0, ""
|
return "main", [], 0, ""
|
||||||
|
|
||||||
commit_sha = data.get('sha', '')[:7]
|
commit_payload = cast(dict[str, Any], data)
|
||||||
commit_message = data.get('commit', {}).get('message', '')
|
commit_sha = commit_payload.get('sha', '')[:7]
|
||||||
commit_date = data.get('commit', {}).get('committer', {}).get('date', '')[:10]
|
commit_message = commit_payload.get('commit', {}).get('message', '')
|
||||||
|
commit_date = commit_payload.get('commit', {}).get('committer', {}).get('date', '')[:10]
|
||||||
|
|
||||||
version = f"main-{commit_sha}"
|
version = f"main-{commit_sha}"
|
||||||
changelog = [commit_message] if commit_message else []
|
changelog = [commit_message] if commit_message else []
|
||||||
@@ -598,10 +600,11 @@ class UpdateRoutes:
|
|||||||
custom_headers={'Accept': 'application/vnd.github+json'}
|
custom_headers={'Accept': 'application/vnd.github+json'}
|
||||||
)
|
)
|
||||||
if c_ok:
|
if c_ok:
|
||||||
if c_data.get('status') in ('ahead', 'diverged'):
|
compare_payload = cast(dict[str, Any], c_data)
|
||||||
behind_by = c_data.get('ahead_by', 0)
|
if compare_payload.get('status') in ('ahead', 'diverged'):
|
||||||
|
behind_by = compare_payload.get('ahead_by', 0)
|
||||||
else:
|
else:
|
||||||
behind_by = c_data.get('behind_by', 0)
|
behind_by = compare_payload.get('behind_by', 0)
|
||||||
|
|
||||||
return version, changelog, behind_by, commit_date
|
return version, changelog, behind_by, commit_date
|
||||||
|
|
||||||
@@ -706,7 +709,7 @@ class UpdateRoutes:
|
|||||||
logger.info(f"Successfully updated to {new_version}")
|
logger.info(f"Successfully updated to {new_version}")
|
||||||
return True, new_version
|
return True, new_version
|
||||||
|
|
||||||
except git.exc.GitError as e:
|
except git.exc.GitError as e: # pyright: ignore[reportAttributeAccessIssue]
|
||||||
logger.error(f"Git error during update: {e}")
|
logger.error(f"Git error during update: {e}")
|
||||||
return False, ""
|
return False, ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -767,7 +770,7 @@ class UpdateRoutes:
|
|||||||
return git_info
|
return git_info
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_remote_version() -> tuple[str, List[str], List[Dict]]:
|
async def _get_remote_version() -> tuple[str, List[str], List[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Fetch remote version from GitHub
|
Fetch remote version from GitHub
|
||||||
Returns:
|
Returns:
|
||||||
@@ -789,7 +792,7 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
# Parse releases
|
# Parse releases
|
||||||
releases = []
|
releases = []
|
||||||
for i, release in enumerate(data):
|
for i, release in enumerate(cast(list[dict[str, Any]], data)):
|
||||||
version = release.get('tag_name', '')
|
version = release.get('tag_name', '')
|
||||||
if not version.startswith('v'):
|
if not version.startswith('v'):
|
||||||
version = f"v{version}"
|
version = f"v{version}"
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ def _render_prompt(template: str, variables: Dict[str, Any]) -> str:
|
|||||||
Uses simple regex substitution — no Jinja2 dependency needed.
|
Uses simple regex substitution — no Jinja2 dependency needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def replace(match: re.Match) -> str:
|
def replace(match: re.Match[str]) -> str:
|
||||||
key = match.group(1).strip()
|
key = match.group(1).strip()
|
||||||
value = variables.get(key, "")
|
value = variables.get(key, "")
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ class PostProcessor:
|
|||||||
normalises every tag to lowercase for case-insensitive dedup.
|
normalises every tag to lowercase for case-insensitive dedup.
|
||||||
"""
|
"""
|
||||||
merged: List[str] = []
|
merged: List[str] = []
|
||||||
seen: set = set()
|
seen: set[str] = set()
|
||||||
for tag in list(existing) + list(new):
|
for tag in list(existing) + list(new):
|
||||||
t = tag.strip().lower()
|
t = tag.strip().lower()
|
||||||
if t and t not in seen:
|
if t and t not in seen:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ _FRONTMATTER_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_skill_file(path: Path) -> tuple[dict, str]:
|
def _parse_skill_file(path: Path) -> tuple[dict[str, Any], str]:
|
||||||
"""Read a prompt definition file (``prompt.md`` or legacy ``SKILL.md``) and
|
"""Read a prompt definition file (``prompt.md`` or legacy ``SKILL.md``) and
|
||||||
return (frontmatter_dict, body_text).
|
return (frontmatter_dict, body_text).
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import html as html_module
|
import html as html_module
|
||||||
import re
|
import re
|
||||||
from typing import List, Tuple
|
from typing import Any, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
_REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
|
_REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
|
||||||
@@ -18,10 +18,10 @@ _REPO_URL_PATTERN = re.compile(r"https?://huggingface\.co/([^/]+/[^/]+)")
|
|||||||
def extract_simple_markdown_images(
|
def extract_simple_markdown_images(
|
||||||
markdown_text: str,
|
markdown_text: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
existing_urls: set | None = None,
|
existing_urls: set[str] | None = None,
|
||||||
default_width: int = 512,
|
default_width: int = 512,
|
||||||
default_height: int = 512,
|
default_height: int = 512,
|
||||||
) -> list[dict]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Extract standalone markdown images from the README body.
|
"""Extract standalone markdown images from the README body.
|
||||||
|
|
||||||
Matches ```` on lines that are NOT part of a markdown table
|
Matches ```` on lines that are NOT part of a markdown table
|
||||||
@@ -36,8 +36,8 @@ def extract_simple_markdown_images(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||||
images: list[dict] = []
|
images: list[dict[str, Any]] = []
|
||||||
seen_urls: set = set(existing_urls) if existing_urls else set()
|
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
|
||||||
|
|
||||||
# Collect lines that are NOT inside fenced code blocks
|
# Collect lines that are NOT inside fenced code blocks
|
||||||
lines = markdown_text.split("\n")
|
lines = markdown_text.split("\n")
|
||||||
@@ -86,10 +86,10 @@ def extract_simple_markdown_images(
|
|||||||
def extract_html_img_tags(
|
def extract_html_img_tags(
|
||||||
markdown_text: str,
|
markdown_text: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
existing_urls: set | None = None,
|
existing_urls: set[str] | None = None,
|
||||||
default_width: int = 512,
|
default_width: int = 512,
|
||||||
default_height: int = 512,
|
default_height: int = 512,
|
||||||
) -> list[dict]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Extract image URLs from HTML ``<img src=\"...\">`` tags in the README.
|
"""Extract image URLs from HTML ``<img src=\"...\">`` tags in the README.
|
||||||
|
|
||||||
Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML
|
Many HF collection repos (e.g. ``deadman44/Z-Image_LoRA``) use raw HTML
|
||||||
@@ -103,8 +103,8 @@ def extract_html_img_tags(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||||
images: list[dict] = []
|
images: list[dict[str, Any]] = []
|
||||||
seen_urls: set = set(existing_urls) if existing_urls else set()
|
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
|
||||||
|
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r'<img\s[^>]*src=\"([^\"]+)\"',
|
r'<img\s[^>]*src=\"([^\"]+)\"',
|
||||||
@@ -175,7 +175,7 @@ def extract_gallery_images(
|
|||||||
repo: str,
|
repo: str,
|
||||||
default_width: int = 512,
|
default_width: int = 512,
|
||||||
default_height: int = 512,
|
default_height: int = 512,
|
||||||
) -> List[dict]:
|
) -> List[dict[str, Any]]:
|
||||||
"""Extract widget/gallery images from the YAML frontmatter of a HF README.
|
"""Extract widget/gallery images from the YAML frontmatter of a HF README.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -196,7 +196,7 @@ def extract_gallery_images(
|
|||||||
if not frontmatter:
|
if not frontmatter:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
images: List[dict] = []
|
images: List[dict[str, Any]] = []
|
||||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||||
w = default_width or 512
|
w = default_width or 512
|
||||||
h = default_height or 512
|
h = default_height or 512
|
||||||
@@ -258,7 +258,7 @@ def extract_gallery_images(
|
|||||||
text = raw_text
|
text = raw_text
|
||||||
|
|
||||||
if url:
|
if url:
|
||||||
image: dict = {
|
image: dict[str, Any] = {
|
||||||
"url": url,
|
"url": url,
|
||||||
"type": "image",
|
"type": "image",
|
||||||
"nsfwLevel": 0,
|
"nsfwLevel": 0,
|
||||||
@@ -276,10 +276,10 @@ def extract_gallery_images(
|
|||||||
def extract_gallery_table_images(
|
def extract_gallery_table_images(
|
||||||
markdown_text: str,
|
markdown_text: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
existing_urls: set | None = None,
|
existing_urls: set[str] | None = None,
|
||||||
default_width: int = 512,
|
default_width: int = 512,
|
||||||
default_height: int = 512,
|
default_height: int = 512,
|
||||||
) -> list[dict]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Extract images from ``| Preview | Prompt |`` markdown gallery tables.
|
"""Extract images from ``| Preview | Prompt |`` markdown gallery tables.
|
||||||
|
|
||||||
Many HF READMEs include a sample-gallery table in the body (outside
|
Many HF READMEs include a sample-gallery table in the body (outside
|
||||||
@@ -295,8 +295,8 @@ def extract_gallery_table_images(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
base_url = f"https://huggingface.co/{repo}/resolve/main"
|
||||||
images: list[dict] = []
|
images: list[dict[str, Any]] = []
|
||||||
seen_urls: set = set(existing_urls) if existing_urls else set()
|
seen_urls: set[str] = set(existing_urls) if existing_urls else set()
|
||||||
lines = markdown_text.split("\n")
|
lines = markdown_text.split("\n")
|
||||||
n = len(lines)
|
n = len(lines)
|
||||||
i = 0
|
i = 0
|
||||||
@@ -514,7 +514,7 @@ def _strip_standalone_images(text: str) -> str:
|
|||||||
URL was stripped entirely, making it impossible for the LLM to return
|
URL was stripped entirely, making it impossible for the LLM to return
|
||||||
a ``preview_url`` for repos that use HTML ``<img>`` tags exclusively.
|
a ``preview_url`` for repos that use HTML ``<img>`` tags exclusively.
|
||||||
"""
|
"""
|
||||||
def _img_to_md(match: re.Match) -> str:
|
def _img_to_md(match: re.Match[str]) -> str:
|
||||||
"""Convert an ``<img>`` tag to markdown image syntax ````."""
|
"""Convert an ``<img>`` tag to markdown image syntax ````."""
|
||||||
tag = match.group(0)
|
tag = match.group(0)
|
||||||
src_m = re.search(r'src="([^"]+)"', tag) or re.search(r"src='([^']+)'", tag)
|
src_m = re.search(r'src="([^"]+)"', tag) or re.search(r"src='([^']+)'", tag)
|
||||||
@@ -942,7 +942,7 @@ def _strip_badge_images(text: str) -> str:
|
|||||||
"twitter", "colab", "gradio", "space",
|
"twitter", "colab", "gradio", "space",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _should_remove(m: re.Match) -> str:
|
def _should_remove(m: re.Match[str]) -> str:
|
||||||
alt = (m.group(1) or "").lower()
|
alt = (m.group(1) or "").lower()
|
||||||
for kw in badge_keywords:
|
for kw in badge_keywords:
|
||||||
if kw in alt:
|
if kw in alt:
|
||||||
|
|||||||
+146
-18
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -7,6 +11,7 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,10 +25,43 @@ from .settings_manager import get_settings_manager
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Maximum times the download poll loop will re-schedule a transfer after it
|
||||||
|
# is lost (daemon restart / RPC outage) before failing the download.
|
||||||
|
MAX_TRANSFER_RECOVERY_ATTEMPTS = 2
|
||||||
|
|
||||||
|
# stderr lines matching these markers indicate a disk write failure inside
|
||||||
|
# aria2 (piece cache flush or raw file write). They are promoted to INFO so
|
||||||
|
# the root cause (disk full, permission denied, file locked by another
|
||||||
|
# process, ...) is visible in the default logs; all other stderr output stays
|
||||||
|
# at DEBUG to avoid noise.
|
||||||
|
_DISK_WRITE_ERROR_MARKERS = (
|
||||||
|
# aria2 wrapper messages (write disk cache flush path)
|
||||||
|
"write disk cache flush failure",
|
||||||
|
"error when trying to flush write cache",
|
||||||
|
"failed to write into the file",
|
||||||
|
"failed to open the file",
|
||||||
|
"failed to seek the file",
|
||||||
|
# underlying root-cause phrases reported via "cause: ..." (POSIX + Windows)
|
||||||
|
"no space left on device",
|
||||||
|
"not enough space on the disk",
|
||||||
|
"input/output error",
|
||||||
|
"permission denied",
|
||||||
|
"access is denied",
|
||||||
|
"disk quota exceeded",
|
||||||
|
"used by another process",
|
||||||
|
"sharing violation",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Minimum interval between INFO-level reports of the same stderr line so a
|
||||||
|
# repeated failure (e.g. aria2 retrying against a full disk) does not spam
|
||||||
|
# the log.
|
||||||
|
STDERR_ERROR_REPORT_INTERVAL = 60.0
|
||||||
|
|
||||||
|
|
||||||
def _try_certifi_ca_path() -> str | None:
|
def _try_certifi_ca_path() -> str | None:
|
||||||
"""Return the certifi CA bundle path if available, else None."""
|
"""Return the certifi CA bundle path if available, else None."""
|
||||||
try:
|
try:
|
||||||
import certifi # type: ignore[import-untyped]
|
import certifi # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
|
||||||
path = certifi.where()
|
path = certifi.where()
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
@@ -81,10 +119,12 @@ class Aria2Downloader:
|
|||||||
self._rpc_session: Optional[aiohttp.ClientSession] = None
|
self._rpc_session: Optional[aiohttp.ClientSession] = None
|
||||||
self._rpc_session_lock = asyncio.Lock()
|
self._rpc_session_lock = asyncio.Lock()
|
||||||
self._process_lock = asyncio.Lock()
|
self._process_lock = asyncio.Lock()
|
||||||
|
self._register_lock = asyncio.Lock()
|
||||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||||
self._poll_interval = 0.5
|
self._poll_interval = 0.5
|
||||||
self._state_store = Aria2TransferStateStore()
|
self._state_store = Aria2TransferStateStore()
|
||||||
self._stderr_reader_task: Optional[asyncio.Task] = None
|
self._stderr_reader_task: Optional[asyncio.Task[Any]] = None
|
||||||
|
self._stderr_error_report: Dict[str, float] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@@ -99,26 +139,58 @@ class Aria2Downloader:
|
|||||||
progress_callback=None,
|
progress_callback=None,
|
||||||
headers: Optional[Dict[str, str]] = None,
|
headers: Optional[Dict[str, str]] = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""Download a file using aria2 RPC and wait for completion."""
|
"""Download a file using aria2 RPC and wait for completion.
|
||||||
|
|
||||||
|
The poll loop is self-healing: when the in-memory transfer entry
|
||||||
|
disappears (e.g. another download restarted the daemon and
|
||||||
|
``close()`` cleared ``_transfers``) or the RPC becomes unreachable,
|
||||||
|
the transfer is re-scheduled with ``continue=true`` so the download
|
||||||
|
resumes from the on-disk ``.aria2`` control file. Recovery is bounded
|
||||||
|
by ``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
||||||
|
"""
|
||||||
|
|
||||||
await self._ensure_process()
|
await self._ensure_process()
|
||||||
save_path = os.path.abspath(save_path)
|
save_path = os.path.abspath(save_path)
|
||||||
transfer = self._transfers.get(download_id)
|
|
||||||
if transfer is None or os.path.abspath(transfer.save_path) != save_path:
|
|
||||||
gid = await self._schedule_download(
|
|
||||||
url,
|
|
||||||
save_path,
|
|
||||||
download_id=download_id,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
transfer = Aria2Transfer(gid=gid, save_path=save_path)
|
|
||||||
self._transfers[download_id] = transfer
|
|
||||||
|
|
||||||
|
async with self._register_lock:
|
||||||
|
transfer = self._transfers.get(download_id)
|
||||||
|
if transfer is None or os.path.abspath(transfer.save_path) != save_path:
|
||||||
|
transfer = await self._register_transfer(
|
||||||
|
url,
|
||||||
|
save_path,
|
||||||
|
download_id=download_id,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
recovery_attempts = 0
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
status = await self._get_status_with_retry(download_id)
|
try:
|
||||||
|
status = await self._get_status_with_retry(download_id)
|
||||||
|
except Aria2Error:
|
||||||
|
status = None
|
||||||
|
|
||||||
if status is None:
|
if status is None:
|
||||||
return False, "aria2 download not found"
|
if recovery_attempts >= MAX_TRANSFER_RECOVERY_ATTEMPTS:
|
||||||
|
return False, "aria2 download not found"
|
||||||
|
recovery_attempts += 1
|
||||||
|
logger.warning(
|
||||||
|
"aria2 transfer %s lost; re-scheduling with resume "
|
||||||
|
"(attempt %d/%d)",
|
||||||
|
download_id,
|
||||||
|
recovery_attempts,
|
||||||
|
MAX_TRANSFER_RECOVERY_ATTEMPTS,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
await self._ensure_process()
|
||||||
|
async with self._register_lock:
|
||||||
|
transfer = await self._register_transfer(
|
||||||
|
url,
|
||||||
|
save_path,
|
||||||
|
download_id=download_id,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
snapshot = self._build_progress_snapshot(status)
|
snapshot = self._build_progress_snapshot(status)
|
||||||
if progress_callback is not None:
|
if progress_callback is not None:
|
||||||
@@ -135,7 +207,9 @@ class Aria2Downloader:
|
|||||||
|
|
||||||
await asyncio.sleep(self._poll_interval)
|
await asyncio.sleep(self._poll_interval)
|
||||||
finally:
|
finally:
|
||||||
self._transfers.pop(download_id, None)
|
current = self._transfers.get(download_id)
|
||||||
|
if current is not None and current.gid == transfer.gid:
|
||||||
|
self._transfers.pop(download_id, None)
|
||||||
|
|
||||||
async def _get_status_with_retry(
|
async def _get_status_with_retry(
|
||||||
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||||
@@ -190,7 +264,7 @@ class Aria2Downloader:
|
|||||||
download_id,
|
download_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
options: Dict[str, str] = {
|
options: Dict[str, Any] = {
|
||||||
"dir": save_dir,
|
"dir": save_dir,
|
||||||
"out": out_name,
|
"out": out_name,
|
||||||
"continue": "true",
|
"continue": "true",
|
||||||
@@ -238,6 +312,25 @@ class Aria2Downloader:
|
|||||||
)
|
)
|
||||||
return gid
|
return gid
|
||||||
|
|
||||||
|
async def _register_transfer(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
save_path: str,
|
||||||
|
*,
|
||||||
|
download_id: str,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Aria2Transfer:
|
||||||
|
"""Schedule a download and track it in the in-memory transfer registry."""
|
||||||
|
gid = await self._schedule_download(
|
||||||
|
url,
|
||||||
|
save_path,
|
||||||
|
download_id=download_id,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
|
||||||
|
self._transfers[download_id] = transfer
|
||||||
|
return transfer
|
||||||
|
|
||||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Return the raw aria2 status payload for a known download."""
|
"""Return the raw aria2 status payload for a known download."""
|
||||||
|
|
||||||
@@ -385,16 +478,51 @@ class Aria2Downloader:
|
|||||||
blocks, which freezes the entire ``aria2c`` process — including its
|
blocks, which freezes the entire ``aria2c`` process — including its
|
||||||
RPC handler. This background task reads lines from stderr as they
|
RPC handler. This background task reads lines from stderr as they
|
||||||
arrive and forwards them to Python's logger.
|
arrive and forwards them to Python's logger.
|
||||||
|
|
||||||
|
Lines that indicate a disk write failure (e.g. the "cause: No space
|
||||||
|
left on device" line that follows "Write disk cache flush failure")
|
||||||
|
are promoted to INFO so the root cause is visible without enabling
|
||||||
|
debug logging; every other line stays at DEBUG to avoid noise.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
assert self._process is not None and self._process.stderr is not None
|
assert self._process is not None and self._process.stderr is not None
|
||||||
async for line in self._process.stderr:
|
async for line in self._process.stderr:
|
||||||
text = line.decode("utf-8", errors="replace").rstrip()
|
text = line.decode("utf-8", errors="replace").rstrip()
|
||||||
if text:
|
if text:
|
||||||
logger.debug("aria2 stderr: %s", text)
|
if self._is_disk_write_error(text):
|
||||||
|
self._report_stderr_error(text)
|
||||||
|
else:
|
||||||
|
logger.debug("aria2 stderr: %s", text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_disk_write_error(text: str) -> bool:
|
||||||
|
lowered = text.lower()
|
||||||
|
return any(marker in lowered for marker in _DISK_WRITE_ERROR_MARKERS)
|
||||||
|
|
||||||
|
def _report_stderr_error(self, text: str) -> None:
|
||||||
|
"""INFO-log a disk write failure line, rate-limited per line text.
|
||||||
|
|
||||||
|
aria2 re-emits the same error chain on every poll/retry while the
|
||||||
|
underlying condition persists; only the first occurrence within
|
||||||
|
``STDERR_ERROR_REPORT_INTERVAL`` seconds is promoted to INFO.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
last = self._stderr_error_report.get(text)
|
||||||
|
if last is not None and now - last < STDERR_ERROR_REPORT_INTERVAL:
|
||||||
|
logger.debug("aria2 stderr (repeated disk write error): %s", text)
|
||||||
|
return
|
||||||
|
# Drop entries older than the window so the map stays bounded even
|
||||||
|
# during a long disk-full episode (piece indexes change per line).
|
||||||
|
self._stderr_error_report = {
|
||||||
|
line: timestamp
|
||||||
|
for line, timestamp in self._stderr_error_report.items()
|
||||||
|
if now - timestamp < STDERR_ERROR_REPORT_INTERVAL
|
||||||
|
}
|
||||||
|
self._stderr_error_report[text] = now
|
||||||
|
logger.info("aria2 disk write failure: %s", text)
|
||||||
|
|
||||||
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
|
||||||
try:
|
try:
|
||||||
result = callback(snapshot, snapshot)
|
result = callback(snapshot, snapshot)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from filename, base_model, and CivitAI version name — no manual tagging requir
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Set
|
from typing import Any, Dict, List, Set
|
||||||
|
|
||||||
# ── Tag category definitions ──────────────────────────────────────────
|
# ── Tag category definitions ──────────────────────────────────────────
|
||||||
# Each category maps a display label to a regex pattern.
|
# Each category maps a display label to a regex pattern.
|
||||||
@@ -52,7 +52,7 @@ AUTO_TAG_GROUPS = {
|
|||||||
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
|
DEFAULT_ENABLED_GROUPS = {"mode", "video"}
|
||||||
|
|
||||||
|
|
||||||
def _collect_sources(model_data: Dict) -> List[str]:
|
def _collect_sources(model_data: Dict[str, Any]) -> List[str]:
|
||||||
"""Collect all text sources from model data for tag matching."""
|
"""Collect all text sources from model data for tag matching."""
|
||||||
sources: List[str] = []
|
sources: List[str] = []
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ def _collect_sources(model_data: Dict) -> List[str]:
|
|||||||
return sources
|
return sources
|
||||||
|
|
||||||
|
|
||||||
def extract_auto_tags(model_data: Dict) -> List[str]:
|
def extract_auto_tags(model_data: Dict[str, Any]) -> List[str]:
|
||||||
"""Extract auto-detected tags from model metadata.
|
"""Extract auto-detected tags from model metadata.
|
||||||
|
|
||||||
Uses a two-layer approach:
|
Uses a two-layer approach:
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
|
"""Backfill the AutoV3 checked state for models loaded from a persisted snapshot.
|
||||||
|
|
||||||
|
The SQLite persistent cache predates the AutoV3 feature, so entries hydrated
|
||||||
|
from it have a NULL ``autov3`` column (the "not checked yet" state). This
|
||||||
|
service computes the embedded AutoV3 hash for each such model — once per
|
||||||
|
process — and persists it through the scanner's single write path
|
||||||
|
(:meth:`ModelScanner.update_autov3_for_model`), marking every visited row so a
|
||||||
|
subsequent run finds nothing left to do.
|
||||||
|
|
||||||
|
Three-state contract honored here:
|
||||||
|
|
||||||
|
- ``NULL`` (sqlite) / absent (dict) = not checked yet → backfill computes it
|
||||||
|
- ``''`` (sqlite/dict) / JSON null = checked, no value available → never recompute
|
||||||
|
- 12-char lowercase hex = value → never recompute
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - type-check only; runtime imports are local
|
||||||
|
from .model_scanner import ModelScanner
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_autov3(file_path: str) -> str:
|
||||||
|
"""Resolve the AutoV3 hash for a model file.
|
||||||
|
|
||||||
|
Prefers the Civitai AutoV3 reported for the file whose SHA256 matches
|
||||||
|
(the authoritative value for recipe matching); falls back to the embedded
|
||||||
|
safetensors header hash. Returns ``''`` when neither is available.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||||
|
if os.path.exists(metadata_path):
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
from ..utils.models import autov3_from_civitai_files # local import avoids cycles
|
||||||
|
|
||||||
|
sha256 = (payload.get("sha256") or "").lower()
|
||||||
|
civitai_autov3 = autov3_from_civitai_files(payload.get("civitai"), sha256)
|
||||||
|
if civitai_autov3:
|
||||||
|
return civitai_autov3
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
from ..utils.file_utils import calculate_autov3 # local import avoids cycles
|
||||||
|
|
||||||
|
return calculate_autov3(file_path) or ""
|
||||||
|
|
||||||
|
|
||||||
|
class Autov3BackfillService:
|
||||||
|
"""Compute and persist AutoV3 hashes for models missing a checked state."""
|
||||||
|
|
||||||
|
_instance: Optional["Autov3BackfillService"] = None
|
||||||
|
_instance_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Re-entrancy guard per model type: scanners for different model types
|
||||||
|
# initialize concurrently (lora_manager.py), so a global guard would
|
||||||
|
# silently skip every type but the first to start. Each model type
|
||||||
|
# runs its own backfill; a duplicate trigger for the same type no-ops.
|
||||||
|
self._running_types: set[str] = set()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> "Autov3BackfillService":
|
||||||
|
"""Return the process-wide singleton instance."""
|
||||||
|
if cls._instance is None:
|
||||||
|
with cls._instance_lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
async def backfill(self, scanner: "ModelScanner") -> int:
|
||||||
|
"""Compute AutoV3 for every un-checked model of ``scanner.model_type``.
|
||||||
|
|
||||||
|
Each candidate file is read once via :func:`~py.utils.file_utils.calculate_autov3`
|
||||||
|
(cheap: safetensors header only) and the result is persisted through
|
||||||
|
``scanner.update_autov3_for_model``. Files that no longer exist on
|
||||||
|
disk are skipped — they are intentionally NOT marked, because scanner
|
||||||
|
cleanup removes the stale row later.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The number of models successfully updated. Never raises; on any
|
||||||
|
failure a warning is logged and ``0`` is returned. A duplicate
|
||||||
|
trigger for a model type that is already being backfilled returns
|
||||||
|
``0`` immediately; different model types run concurrently.
|
||||||
|
"""
|
||||||
|
model_type = scanner.model_type
|
||||||
|
if model_type in self._running_types:
|
||||||
|
return 0
|
||||||
|
self._running_types.add(model_type)
|
||||||
|
try:
|
||||||
|
# Local imports avoid import cycles at module load time.
|
||||||
|
from .persistent_model_cache import get_persistent_cache
|
||||||
|
from ..utils.file_utils import calculate_autov3
|
||||||
|
|
||||||
|
persistent = getattr(scanner, "_persistent_cache", None) or get_persistent_cache()
|
||||||
|
paths = persistent.get_models_missing_autov3(model_type)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
count = 0
|
||||||
|
for path in paths:
|
||||||
|
# A file that no longer exists must not be marked; scanner
|
||||||
|
# cleanup removes the stale row later. The existence check and
|
||||||
|
# hash resolution run in the executor so the loop stays
|
||||||
|
# responsive to API requests while the backfill iterates a
|
||||||
|
# large library.
|
||||||
|
if not await loop.run_in_executor(None, os.path.exists, path):
|
||||||
|
continue
|
||||||
|
autov3 = await loop.run_in_executor(None, _resolve_autov3, path)
|
||||||
|
if await scanner.update_autov3_for_model(model_type, path, autov3):
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if paths:
|
||||||
|
logger.info(
|
||||||
|
"AutoV3 backfill: updated %d/%d models for %s",
|
||||||
|
count,
|
||||||
|
len(paths),
|
||||||
|
model_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Steady state after the first run: nothing left to backfill.
|
||||||
|
logger.debug("AutoV3 backfill: nothing to process for %s", model_type)
|
||||||
|
return count
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"AutoV3 backfill failed for %s: %s",
|
||||||
|
getattr(scanner, "model_type", "?"),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
self._running_types.discard(model_type)
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import random
|
import random
|
||||||
from typing import Any, Dict, List, Optional, Type, Union, TYPE_CHECKING
|
from typing import Any, Awaitable, Dict, List, Optional, Type, Union, TYPE_CHECKING, cast
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -70,24 +70,24 @@ class BaseModelService(ABC):
|
|||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
sort_by: str = "name",
|
sort_by: str = "name",
|
||||||
folder: str = None,
|
folder: str | None = None,
|
||||||
folder_include: list = None,
|
folder_include: list[str] | None = None,
|
||||||
folder_exclude: list = None,
|
folder_exclude: list[str] | None = None,
|
||||||
search: str = None,
|
search: str | None = None,
|
||||||
fuzzy_search: bool = False,
|
fuzzy_search: bool = False,
|
||||||
base_models: list = None,
|
base_models: list[str] | None = None,
|
||||||
model_types: list = None,
|
model_types: list[str] | None = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
auto_tags: Optional[Dict[str, str]] = None,
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
search_options: dict = None,
|
search_options: dict[str, Any] | None = None,
|
||||||
hash_filters: dict = None,
|
hash_filters: dict[str, Any] | None = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
update_available_only: bool = False,
|
update_available_only: bool = False,
|
||||||
credit_required: Optional[bool] = None,
|
credit_required: Optional[bool] = None,
|
||||||
allow_selling_generated_content: Optional[bool] = None,
|
allow_selling_generated_content: Optional[bool] = None,
|
||||||
tag_logic: str = "any",
|
tag_logic: str = "any",
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Dict:
|
) -> Dict[str, Any]:
|
||||||
"""Get paginated and filtered model data"""
|
"""Get paginated and filtered model data"""
|
||||||
overall_start = time.perf_counter()
|
overall_start = time.perf_counter()
|
||||||
|
|
||||||
@@ -178,8 +178,8 @@ class BaseModelService(ABC):
|
|||||||
ufs = self.settings.get("version_grouping", "same_base")
|
ufs = self.settings.get("version_grouping", "same_base")
|
||||||
group_by_base = ufs == "same_base"
|
group_by_base = ufs == "same_base"
|
||||||
|
|
||||||
model_groups: Dict[Any, List[Dict]] = {}
|
model_groups: Dict[Any, List[Dict[str, Any]]] = {}
|
||||||
ungrouped_standalone: List[Dict] = []
|
ungrouped_standalone: List[Dict[str, Any]] = []
|
||||||
for item in sorted_data:
|
for item in sorted_data:
|
||||||
mid = self._extract_group_key(item)
|
mid = self._extract_group_key(item)
|
||||||
if mid is None:
|
if mid is None:
|
||||||
@@ -249,7 +249,7 @@ class BaseModelService(ABC):
|
|||||||
filter_duration = time.perf_counter() - t1
|
filter_duration = time.perf_counter() - t1
|
||||||
post_filter_count = len(filtered_data)
|
post_filter_count = len(filtered_data)
|
||||||
|
|
||||||
annotated_for_filter: Optional[List[Dict]] = None
|
annotated_for_filter: Optional[List[Dict[str, Any]]] = None
|
||||||
t2 = time.perf_counter()
|
t2 = time.perf_counter()
|
||||||
if update_available_only:
|
if update_available_only:
|
||||||
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
annotated_for_filter = await self._annotate_update_flags(filtered_data)
|
||||||
@@ -296,11 +296,11 @@ class BaseModelService(ABC):
|
|||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
sort_by: str = "name",
|
sort_by: str = "name",
|
||||||
search: str = None,
|
search: str | None = None,
|
||||||
fuzzy_search: bool = False,
|
fuzzy_search: bool = False,
|
||||||
search_options: dict = None,
|
search_options: dict[str, Any] | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Dict:
|
) -> Dict[str, Any]:
|
||||||
"""Get paginated excluded model data."""
|
"""Get paginated excluded model data."""
|
||||||
excluded_paths = list(self.scanner.get_excluded_models())
|
excluded_paths = list(self.scanner.get_excluded_models())
|
||||||
excluded_entries: List[Dict[str, Any]] = []
|
excluded_entries: List[Dict[str, Any]] = []
|
||||||
@@ -326,7 +326,7 @@ class BaseModelService(ABC):
|
|||||||
]
|
]
|
||||||
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
|
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
|
||||||
if callable(persist_current_cache):
|
if callable(persist_current_cache):
|
||||||
await persist_current_cache()
|
await cast(Awaitable[Any], persist_current_cache())
|
||||||
|
|
||||||
excluded_entries = self._sort_entries(excluded_entries, sort_by)
|
excluded_entries = self._sort_entries(excluded_entries, sort_by)
|
||||||
|
|
||||||
@@ -444,39 +444,50 @@ class BaseModelService(ABC):
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
async def _apply_hash_filters(
|
async def _apply_hash_filters(
|
||||||
self, data: List[Dict], hash_filters: Dict
|
self, data: List[Dict[str, Any]], hash_filters: Dict[str, Any]
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Apply hash-based filtering"""
|
"""Apply hash-based filtering (SHA256 and AutoV3)."""
|
||||||
|
|
||||||
|
def matches_hash_set(item: Dict[str, Any], hash_set: set[str]) -> bool:
|
||||||
|
"""Check whether an item matches any hash in the set.
|
||||||
|
|
||||||
|
Compares the item's ``sha256`` field and its non-empty ``autov3``
|
||||||
|
field, both case-insensitively.
|
||||||
|
"""
|
||||||
|
if item.get("sha256", "").lower() in hash_set:
|
||||||
|
return True
|
||||||
|
autov3 = item.get("autov3", "")
|
||||||
|
return bool(autov3) and autov3.lower() in hash_set
|
||||||
|
|
||||||
single_hash = hash_filters.get("single_hash")
|
single_hash = hash_filters.get("single_hash")
|
||||||
multiple_hashes = hash_filters.get("multiple_hashes")
|
multiple_hashes = hash_filters.get("multiple_hashes")
|
||||||
|
|
||||||
if single_hash:
|
if single_hash:
|
||||||
# Filter by single hash
|
# Filter by single hash (SHA256 or AutoV3)
|
||||||
single_hash = single_hash.lower()
|
|
||||||
return [
|
return [
|
||||||
item for item in data if item.get("sha256", "").lower() == single_hash
|
item for item in data if matches_hash_set(item, {single_hash.lower()})
|
||||||
]
|
]
|
||||||
elif multiple_hashes:
|
elif multiple_hashes:
|
||||||
# Filter by multiple hashes
|
# Filter by multiple hashes (SHA256 or AutoV3)
|
||||||
hash_set = set(hash.lower() for hash in multiple_hashes)
|
hash_set = {hash.lower() for hash in multiple_hashes}
|
||||||
return [item for item in data if item.get("sha256", "").lower() in hash_set]
|
return [item for item in data if matches_hash_set(item, hash_set)]
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def _apply_common_filters(
|
async def _apply_common_filters(
|
||||||
self,
|
self,
|
||||||
data: List[Dict],
|
data: List[Dict[str, Any]],
|
||||||
folder: str = None,
|
folder: str | None = None,
|
||||||
folder_include: list = None,
|
folder_include: list[str] | None = None,
|
||||||
folder_exclude: list = None,
|
folder_exclude: list[str] | None = None,
|
||||||
base_models: list = None,
|
base_models: list[str] | None = None,
|
||||||
model_types: list = None,
|
model_types: list[str] | None = None,
|
||||||
tags: Optional[Dict[str, str]] = None,
|
tags: Optional[Dict[str, str]] = None,
|
||||||
auto_tags: Optional[Dict[str, str]] = None,
|
auto_tags: Optional[Dict[str, str]] = None,
|
||||||
favorites_only: bool = False,
|
favorites_only: bool = False,
|
||||||
search_options: dict = None,
|
search_options: dict[str, Any] | None = None,
|
||||||
tag_logic: str = "any",
|
tag_logic: str = "any",
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Apply common filters that work across all model types"""
|
"""Apply common filters that work across all model types"""
|
||||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||||
criteria = FilterCriteria(
|
criteria = FilterCriteria(
|
||||||
@@ -495,24 +506,24 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
async def _apply_search_filters(
|
async def _apply_search_filters(
|
||||||
self,
|
self,
|
||||||
data: List[Dict],
|
data: List[Dict[str, Any]],
|
||||||
search: str,
|
search: str,
|
||||||
fuzzy_search: bool,
|
fuzzy_search: bool,
|
||||||
search_options: dict,
|
search_options: dict[str, Any] | None,
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Apply search filtering"""
|
"""Apply search filtering"""
|
||||||
normalized_options = self.search_strategy.normalize_options(search_options)
|
normalized_options = self.search_strategy.normalize_options(search_options)
|
||||||
return self.search_strategy.apply(
|
return self.search_strategy.apply(
|
||||||
data, search, normalized_options, fuzzy_search
|
data, search, normalized_options, fuzzy_search
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
|
||||||
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
"""Apply model-specific filters - to be overridden by subclasses if needed"""
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def _apply_credit_required_filter(
|
async def _apply_credit_required_filter(
|
||||||
self, data: List[Dict], credit_required: bool
|
self, data: List[Dict[str, Any]], credit_required: bool
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Apply credit required filtering based on license_flags.
|
"""Apply credit required filtering based on license_flags.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -542,8 +553,8 @@ class BaseModelService(ABC):
|
|||||||
return filtered_data
|
return filtered_data
|
||||||
|
|
||||||
async def _apply_allow_selling_filter(
|
async def _apply_allow_selling_filter(
|
||||||
self, data: List[Dict], allow_selling: bool
|
self, data: List[Dict[str, Any]], allow_selling: bool
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Apply allow selling generated content filtering based on license_flags.
|
"""Apply allow selling generated content filtering based on license_flags.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -575,8 +586,8 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
async def _annotate_update_flags(
|
async def _annotate_update_flags(
|
||||||
self,
|
self,
|
||||||
items: List[Dict],
|
items: List[Dict[str, Any]],
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Attach an update_available flag to each response item.
|
"""Attach an update_available flag to each response item.
|
||||||
|
|
||||||
Items without a civitai model id default to False.
|
Items without a civitai model id default to False.
|
||||||
@@ -591,7 +602,7 @@ class BaseModelService(ABC):
|
|||||||
item["update_available"] = False
|
item["update_available"] = False
|
||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
id_to_items: Dict[int, List[Dict]] = {}
|
id_to_items: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
ordered_ids: List[int] = []
|
ordered_ids: List[int] = []
|
||||||
for item in annotated:
|
for item in annotated:
|
||||||
model_id = self._extract_model_id(item)
|
model_id = self._extract_model_id(item)
|
||||||
@@ -628,7 +639,7 @@ class BaseModelService(ABC):
|
|||||||
record_method = getattr(self.update_service, "get_records_bulk", None)
|
record_method = getattr(self.update_service, "get_records_bulk", None)
|
||||||
if callable(record_method):
|
if callable(record_method):
|
||||||
try:
|
try:
|
||||||
records = await record_method(self.model_type, ordered_ids)
|
records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
|
||||||
resolved = {
|
resolved = {
|
||||||
model_id: record.has_update(hide_early_access=hide_early_access)
|
model_id: record.has_update(hide_early_access=hide_early_access)
|
||||||
for model_id, record in records.items()
|
for model_id, record in records.items()
|
||||||
@@ -648,11 +659,11 @@ class BaseModelService(ABC):
|
|||||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||||
if callable(bulk_method):
|
if callable(bulk_method):
|
||||||
try:
|
try:
|
||||||
resolved = await bulk_method(
|
resolved = await cast(Awaitable[Any], bulk_method(
|
||||||
self.model_type,
|
self.model_type,
|
||||||
ordered_ids,
|
ordered_ids,
|
||||||
hide_early_access=hide_early_access,
|
hide_early_access=hide_early_access,
|
||||||
)
|
))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||||
@@ -714,7 +725,7 @@ class BaseModelService(ABC):
|
|||||||
return annotated
|
return annotated
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_hf_group_key(item: Dict) -> Optional[str]:
|
def _extract_hf_group_key(item: Dict[str, Any]) -> Optional[str]:
|
||||||
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
|
"""Extract `hf:{owner}/{repo}` from item's ``hf_url``, or None."""
|
||||||
hf_url = item.get("hf_url") if isinstance(item, dict) else None
|
hf_url = item.get("hf_url") if isinstance(item, dict) else None
|
||||||
if not hf_url or not isinstance(hf_url, str):
|
if not hf_url or not isinstance(hf_url, str):
|
||||||
@@ -727,7 +738,7 @@ class BaseModelService(ABC):
|
|||||||
return f"hf:{m.group(1)}"
|
return f"hf:{m.group(1)}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_group_key(item: Dict) -> Union[int, str, None]:
|
def _extract_group_key(item: Dict[str, Any]) -> Union[int, str, None]:
|
||||||
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
|
"""Return the group identity key: CivitAI modelId (int) or HF repo (str).
|
||||||
|
|
||||||
Preference order:
|
Preference order:
|
||||||
@@ -741,7 +752,7 @@ class BaseModelService(ABC):
|
|||||||
return BaseModelService._extract_hf_group_key(item)
|
return BaseModelService._extract_hf_group_key(item)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_model_id(item: Dict) -> Optional[int]:
|
def _extract_model_id(item: Dict[str, Any]) -> Optional[int]:
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
return None
|
return None
|
||||||
@@ -754,7 +765,7 @@ class BaseModelService(ABC):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_version_id(item: Dict) -> Optional[int]:
|
def _extract_version_id(item: Dict[str, Any]) -> Optional[int]:
|
||||||
civitai = item.get("civitai") if isinstance(item, dict) else None
|
civitai = item.get("civitai") if isinstance(item, dict) else None
|
||||||
if not isinstance(civitai, dict):
|
if not isinstance(civitai, dict):
|
||||||
return None
|
return None
|
||||||
@@ -767,7 +778,7 @@ class BaseModelService(ABC):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_base_model(item: Dict) -> Optional[str]:
|
def _extract_base_model(item: Dict[str, Any]) -> Optional[str]:
|
||||||
value = item.get("base_model")
|
value = item.get("base_model")
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -819,7 +830,7 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return highest_by_base
|
return highest_by_base
|
||||||
|
|
||||||
def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict:
|
def _paginate(self, data: List[Dict[str, Any]], page: int, page_size: int) -> Dict[str, Any]:
|
||||||
"""Apply pagination to filtered data"""
|
"""Apply pagination to filtered data"""
|
||||||
total_items = len(data)
|
total_items = len(data)
|
||||||
start_idx = (page - 1) * page_size
|
start_idx = (page - 1) * page_size
|
||||||
@@ -834,7 +845,7 @@ class BaseModelService(ABC):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def format_response(self, model_data: Dict) -> Optional[Dict]:
|
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Format model data for API response - must be implemented by subclasses.
|
"""Format model data for API response - must be implemented by subclasses.
|
||||||
|
|
||||||
Subclasses should return None for corrupted entries so the handler
|
Subclasses should return None for corrupted entries so the handler
|
||||||
@@ -843,17 +854,17 @@ class BaseModelService(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Common service methods that delegate to scanner
|
# Common service methods that delegate to scanner
|
||||||
async def get_top_tags(self, limit: int = 20) -> List[Dict]:
|
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
"""Get top tags sorted by frequency"""
|
"""Get top tags sorted by frequency"""
|
||||||
return await self.scanner.get_top_tags(limit)
|
return await self.scanner.get_top_tags(limit)
|
||||||
|
|
||||||
async def search_tags(
|
async def search_tags(
|
||||||
self, query: str, limit: int = 50
|
self, query: str, limit: int = 50
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Search tags by substring, sorted by frequency"""
|
"""Search tags by substring, sorted by frequency"""
|
||||||
return await self.scanner.search_tags(query, limit)
|
return await self.scanner.search_tags(query, limit)
|
||||||
|
|
||||||
async def get_base_models(self, limit: int = 20) -> List[Dict]:
|
async def get_base_models(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
"""Get base models sorted by frequency"""
|
"""Get base models sorted by frequency"""
|
||||||
return await self.scanner.get_base_models(limit)
|
return await self.scanner.get_base_models(limit)
|
||||||
|
|
||||||
@@ -920,7 +931,7 @@ class BaseModelService(ABC):
|
|||||||
"""Get model root directories"""
|
"""Get model root directories"""
|
||||||
return self.scanner.get_model_roots()
|
return self.scanner.get_model_roots()
|
||||||
|
|
||||||
def filter_civitai_data(self, data: Dict, minimal: bool = False) -> Dict:
|
def filter_civitai_data(self, data: Dict[str, Any], minimal: bool = False) -> Dict[str, Any]:
|
||||||
"""Filter relevant fields from CivitAI data"""
|
"""Filter relevant fields from CivitAI data"""
|
||||||
if not data:
|
if not data:
|
||||||
return {}
|
return {}
|
||||||
@@ -946,7 +957,7 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
return {k: data[k] for k in fields if k in data}
|
return {k: data[k] for k in fields if k in data}
|
||||||
|
|
||||||
async def get_folder_tree(self, model_root: str) -> Dict:
|
async def get_folder_tree(self, model_root: str) -> Dict[str, Any]:
|
||||||
"""Get hierarchical folder tree for a specific model root"""
|
"""Get hierarchical folder tree for a specific model root"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
@@ -975,7 +986,7 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
async def get_unified_folder_tree(self) -> Dict:
|
async def get_unified_folder_tree(self) -> Dict[str, Any]:
|
||||||
"""Get unified folder tree across all model roots"""
|
"""Get unified folder tree across all model roots"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
@@ -1004,7 +1015,7 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return unified_tree
|
return unified_tree
|
||||||
|
|
||||||
async def get_model_notes(self, model_name: str) -> Optional[dict]:
|
async def get_model_notes(self, model_name: str) -> Optional[dict[str, Any]]:
|
||||||
"""Get notes and file_path for a specific model file.
|
"""Get notes and file_path for a specific model file.
|
||||||
|
|
||||||
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
|
Supports both simple names (``OWSMianne_ANIMA_V1``) and full-path
|
||||||
@@ -1136,7 +1147,7 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
return {"civitai_url": None, "model_id": None, "version_id": None}
|
return {"civitai_url": None, "model_id": None, "version_id": None}
|
||||||
|
|
||||||
async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
|
async def get_model_metadata(self, file_path: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Load full metadata for a single model.
|
"""Load full metadata for a single model.
|
||||||
|
|
||||||
Listing/search endpoints return lightweight cache entries; this method performs
|
Listing/search endpoints return lightweight cache entries; this method performs
|
||||||
@@ -1232,7 +1243,7 @@ class BaseModelService(ABC):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple[int, int, int, str]:
|
||||||
"""Sort paths by how well they satisfy the include tokens.
|
"""Sort paths by how well they satisfy the include tokens.
|
||||||
|
|
||||||
Sorts based on path without extension for consistent ordering.
|
Sorts based on path without extension for consistent ordering.
|
||||||
@@ -1259,19 +1270,87 @@ class BaseModelService(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def search_relative_paths(
|
async def search_relative_paths(
|
||||||
self, search_term: str, limit: int = 15, offset: int = 0
|
self,
|
||||||
|
search_term: str,
|
||||||
|
limit: int = 15,
|
||||||
|
offset: int = 0,
|
||||||
|
*,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
folder_include: Optional[list[str]] = None,
|
||||||
|
folder_exclude: Optional[list[str]] = None,
|
||||||
|
base_models: Optional[list[str]] = None,
|
||||||
|
model_types: Optional[list[str]] = None,
|
||||||
|
tags: Optional[dict[str, str]] = None,
|
||||||
|
auto_tags: Optional[dict[str, str]] = None,
|
||||||
|
tag_logic: str = "any",
|
||||||
|
credit_required: Optional[bool] = None,
|
||||||
|
allow_selling_generated_content: Optional[bool] = None,
|
||||||
|
recursive: bool = True,
|
||||||
|
apply_filters: bool = False,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Search model relative file paths for autocomplete functionality"""
|
"""Search model relative file paths for autocomplete functionality.
|
||||||
|
|
||||||
|
Optional filter kwargs mirror the filters used by the list endpoint
|
||||||
|
(/api/lm/{prefix}/list). When no filter kwargs are provided the
|
||||||
|
behavior is identical to plain token-based path matching.
|
||||||
|
"""
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
include_terms, exclude_terms = self._parse_search_tokens(search_term)
|
||||||
|
|
||||||
|
data = cache.raw_data
|
||||||
|
has_filters = any(
|
||||||
|
[
|
||||||
|
apply_filters,
|
||||||
|
folder is not None,
|
||||||
|
folder_include,
|
||||||
|
folder_exclude,
|
||||||
|
base_models,
|
||||||
|
model_types,
|
||||||
|
tags,
|
||||||
|
auto_tags,
|
||||||
|
credit_required is not None,
|
||||||
|
allow_selling_generated_content is not None,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if has_filters:
|
||||||
|
# Auto-tags are not stored in the scanner cache — they are computed
|
||||||
|
# on the fly. Pre-compute them only when an auto-tag filter is
|
||||||
|
# active to avoid mutating cache entries unnecessarily.
|
||||||
|
if auto_tags:
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
if not item.get("auto_tags"):
|
||||||
|
item["auto_tags"] = extract_auto_tags(item)
|
||||||
|
|
||||||
|
criteria = FilterCriteria(
|
||||||
|
folder=folder,
|
||||||
|
folder_include=folder_include,
|
||||||
|
folder_exclude=folder_exclude,
|
||||||
|
base_models=base_models,
|
||||||
|
model_types=model_types,
|
||||||
|
tags=tags,
|
||||||
|
auto_tags=auto_tags,
|
||||||
|
search_options={"recursive": recursive},
|
||||||
|
tag_logic=tag_logic,
|
||||||
|
)
|
||||||
|
data = self.filter_set.apply(data, criteria)
|
||||||
|
if credit_required is not None:
|
||||||
|
data = await self._apply_credit_required_filter(
|
||||||
|
data, credit_required
|
||||||
|
)
|
||||||
|
if allow_selling_generated_content is not None:
|
||||||
|
data = await self._apply_allow_selling_filter(
|
||||||
|
data, allow_selling_generated_content
|
||||||
|
)
|
||||||
|
|
||||||
matching_paths = []
|
matching_paths = []
|
||||||
|
|
||||||
# Get model roots for path calculation
|
# Get model roots for path calculation
|
||||||
model_roots = self.scanner.get_model_roots()
|
model_roots = self.scanner.get_model_roots()
|
||||||
|
|
||||||
# Collect all matching paths first (needed for proper sorting and offset)
|
# Collect all matching paths first (needed for proper sorting and offset)
|
||||||
for model in cache.raw_data:
|
for model in data:
|
||||||
file_path = model.get("file_path", "")
|
file_path = model.get("file_path", "")
|
||||||
if not file_path:
|
if not file_path:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class CacheEntryValidator:
|
|||||||
'notes': ('', False),
|
'notes': ('', False),
|
||||||
'usage_tips': ('', False),
|
'usage_tips': ('', False),
|
||||||
'hash_status': ('completed', False),
|
'hash_status': ('completed', False),
|
||||||
|
'autov3': (None, False),
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -119,8 +120,13 @@ class CacheEntryValidator:
|
|||||||
if is_required:
|
if is_required:
|
||||||
errors.append(f"Required field '{field_name}' is missing or None")
|
errors.append(f"Required field '{field_name}' is missing or None")
|
||||||
if auto_repair:
|
if auto_repair:
|
||||||
working_entry[field_name] = cls._get_default_copy(default_value)
|
# A missing optional field whose default is None is already
|
||||||
repaired = True
|
# semantically equal to its default (e.g. autov3: absent
|
||||||
|
# means "not checked") — writing None back is a no-op, not
|
||||||
|
# a repair.
|
||||||
|
if default_value is not None:
|
||||||
|
working_entry[field_name] = cls._get_default_copy(default_value)
|
||||||
|
repaired = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Validate field type and value
|
# Validate field type and value
|
||||||
@@ -175,6 +181,15 @@ class CacheEntryValidator:
|
|||||||
# that invalidates the entry, but we also don't mark it repaired.
|
# that invalidates the entry, but we also don't mark it repaired.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Normalize autov3 to lowercase if needed (optional field, never stripped).
|
||||||
|
autov3 = working_entry.get('autov3')
|
||||||
|
if isinstance(autov3, str) and autov3:
|
||||||
|
normalized_autov3 = autov3.lower()
|
||||||
|
if normalized_autov3 != autov3:
|
||||||
|
if auto_repair:
|
||||||
|
working_entry['autov3'] = normalized_autov3
|
||||||
|
repaired = True
|
||||||
|
|
||||||
# Determine if entry is valid
|
# Determine if entry is valid
|
||||||
# Entry is valid if no critical required field errors remain after repair
|
# Entry is valid if no critical required field errors remain after repair
|
||||||
# Critical fields are file_path and sha256
|
# Critical fields are file_path and sha256
|
||||||
@@ -242,6 +257,19 @@ class CacheEntryValidator:
|
|||||||
"""
|
"""
|
||||||
expected_type = type(default_value)
|
expected_type = type(default_value)
|
||||||
|
|
||||||
|
# Special case: autov3 is optional with a three-state contract.
|
||||||
|
# None = not checked, "" = checked but unavailable, otherwise a
|
||||||
|
# 12-character hex string (case-insensitive here; normalized to
|
||||||
|
# lowercase separately).
|
||||||
|
if field_name == 'autov3':
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return f"Field 'autov3' should be string or None, got {type(value).__name__}"
|
||||||
|
if len(value) != 12 or any(c not in '0123456789abcdefABCDEF' for c in value):
|
||||||
|
return "Field 'autov3' should be a 12-character hex string"
|
||||||
|
return None
|
||||||
|
|
||||||
# Special handling for numeric types
|
# Special handling for numeric types
|
||||||
if expected_type == int:
|
if expected_type == int:
|
||||||
if not isinstance(value, (int, float)):
|
if not isinstance(value, (int, float)):
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -6,10 +10,10 @@ from datetime import datetime
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from ..utils.models import CheckpointMetadata
|
from ..utils.models import CheckpointMetadata
|
||||||
from ..utils.file_utils import find_preview_file, normalize_path
|
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from .model_scanner import ModelScanner
|
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||||
from .model_hash_index import ModelHashIndex
|
from .model_hash_index import ModelHashIndex
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -62,6 +66,11 @@ class CheckpointScanner(ModelScanner):
|
|||||||
# Find preview image
|
# Find preview image
|
||||||
preview_url = find_preview_file(base_name, dir_path)
|
preview_url = find_preview_file(base_name, dir_path)
|
||||||
|
|
||||||
|
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||||
|
# large checkpoints; record the checked state at creation time ("" =
|
||||||
|
# checked but unavailable).
|
||||||
|
autov3 = calculate_autov3(real_path)
|
||||||
|
|
||||||
# Create metadata WITHOUT calculating hash
|
# Create metadata WITHOUT calculating hash
|
||||||
metadata = CheckpointMetadata(
|
metadata = CheckpointMetadata(
|
||||||
file_name=base_name,
|
file_name=base_name,
|
||||||
@@ -77,6 +86,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
sub_type="checkpoint",
|
sub_type="checkpoint",
|
||||||
from_civitai=False, # Mark as local model since no hash yet
|
from_civitai=False, # Mark as local model since no hash yet
|
||||||
hash_status="pending", # Mark hash as pending
|
hash_status="pending", # Mark hash as pending
|
||||||
|
autov3=autov3 or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save the created metadata
|
# Save the created metadata
|
||||||
@@ -120,7 +130,11 @@ class CheckpointScanner(ModelScanner):
|
|||||||
# that queries get_hash_by_filename first) will miss on every
|
# that queries get_hash_by_filename first) will miss on every
|
||||||
# lookup and keep calling back into this method, creating a
|
# lookup and keep calling back into this method, creating a
|
||||||
# tight loop that never populates the index.
|
# tight loop that never populates the index.
|
||||||
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
self._hash_index.add_entry(
|
||||||
|
metadata.sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
async with self._hash_calculation_lock:
|
async with self._hash_calculation_lock:
|
||||||
@@ -132,7 +146,11 @@ class CheckpointScanner(ModelScanner):
|
|||||||
and metadata.hash_status == "completed"
|
and metadata.hash_status == "completed"
|
||||||
and metadata.sha256
|
and metadata.sha256
|
||||||
):
|
):
|
||||||
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
self._hash_index.add_entry(
|
||||||
|
metadata.sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
task = self._hash_calculation_tasks.get(real_path)
|
task = self._hash_calculation_tasks.get(real_path)
|
||||||
@@ -185,7 +203,11 @@ class CheckpointScanner(ModelScanner):
|
|||||||
if metadata.hash_status == "completed" and metadata.sha256:
|
if metadata.hash_status == "completed" and metadata.sha256:
|
||||||
# Populate the in-memory hash index even for pre-computed
|
# Populate the in-memory hash index even for pre-computed
|
||||||
# hashes, mirroring the fix in calculate_hash_for_model.
|
# hashes, mirroring the fix in calculate_hash_for_model.
|
||||||
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
|
self._hash_index.add_entry(
|
||||||
|
metadata.sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
return metadata.sha256
|
return metadata.sha256
|
||||||
|
|
||||||
# Update status to calculating
|
# Update status to calculating
|
||||||
@@ -202,7 +224,11 @@ class CheckpointScanner(ModelScanner):
|
|||||||
await MetadataManager.save_metadata(file_path, metadata)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
# Update hash index
|
# Update hash index
|
||||||
self._hash_index.add_entry(sha256.lower(), file_path)
|
self._hash_index.add_entry(
|
||||||
|
sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
|
|
||||||
# Update the in-memory cache entry so that subsequent
|
# Update the in-memory cache entry so that subsequent
|
||||||
# _persist_current_cache / _save_persistent_cache calls
|
# _persist_current_cache / _save_persistent_cache calls
|
||||||
@@ -216,6 +242,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
if entry.get("file_path") == file_path:
|
if entry.get("file_path") == file_path:
|
||||||
entry["sha256"] = sha256.lower()
|
entry["sha256"] = sha256.lower()
|
||||||
entry["hash_status"] = "completed"
|
entry["hash_status"] = "completed"
|
||||||
|
self.bump_cache_version()
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info(f"Hash calculated for checkpoint: {file_path}")
|
logger.info(f"Hash calculated for checkpoint: {file_path}")
|
||||||
@@ -301,7 +328,8 @@ class CheckpointScanner(ModelScanner):
|
|||||||
if not os.path.exists(root_path):
|
if not os.path.exists(root_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||||
|
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
if not filename.endswith(".metadata.json"):
|
if not filename.endswith(".metadata.json"):
|
||||||
continue
|
continue
|
||||||
@@ -405,7 +433,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
roots.extend(config.extra_checkpoints_roots or [])
|
roots.extend(config.extra_checkpoints_roots or [])
|
||||||
roots.extend(config.extra_unet_roots or [])
|
roots.extend(config.extra_unet_roots or [])
|
||||||
# Remove duplicates while preserving order
|
# Remove duplicates while preserving order
|
||||||
seen: set = set()
|
seen: set[str] = set()
|
||||||
unique_roots: List[str] = []
|
unique_roots: List[str] = []
|
||||||
for root in roots:
|
for root in roots:
|
||||||
if root not in seen:
|
if root not in seen:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,58 +21,58 @@ class CheckpointService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, checkpoint_data: Dict) -> Optional[Dict]:
|
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Format Checkpoint data for API response.
|
"""Format Checkpoint data for API response.
|
||||||
|
|
||||||
Returns None when the entry is missing critical fields (corrupted cache
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
row), so the handler layer can filter it out. See issue #730.
|
row), so the handler layer can filter it out. See issue #730.
|
||||||
"""
|
"""
|
||||||
# Guard against corrupted cache entries missing critical fields
|
# Guard against corrupted cache entries missing critical fields
|
||||||
file_path = checkpoint_data.get("file_path")
|
file_path = model_data.get("file_path")
|
||||||
if not file_path or not isinstance(file_path, str):
|
if not file_path or not isinstance(file_path, str):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Skipping corrupted checkpoint entry (missing file_path): %s",
|
"Skipping corrupted checkpoint entry (missing file_path): %s",
|
||||||
checkpoint_data.get("file_name", "<unknown>"),
|
model_data.get("file_name", "<unknown>"),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get sub_type from cache entry (new canonical field)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = checkpoint_data.get("sub_type", "checkpoint")
|
sub_type = model_data.get("sub_type", "checkpoint")
|
||||||
|
|
||||||
file_name = checkpoint_data.get("file_name") or ""
|
file_name = model_data.get("file_name") or ""
|
||||||
model_name = checkpoint_data.get("model_name") or file_name
|
model_name = model_data.get("model_name") or file_name
|
||||||
folder = checkpoint_data.get("folder") or ""
|
folder = model_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
"file_name": file_name,
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(checkpoint_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": checkpoint_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": checkpoint_data.get("base_model", ""),
|
"base_model": model_data.get("base_model", ""),
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
"sha256": checkpoint_data.get("sha256", ""),
|
"sha256": model_data.get("sha256", ""),
|
||||||
"file_path": file_path.replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": checkpoint_data.get("size", 0),
|
"file_size": model_data.get("size", 0),
|
||||||
"modified": checkpoint_data.get("modified", ""),
|
"modified": model_data.get("modified", ""),
|
||||||
"tags": checkpoint_data.get("tags", []),
|
"tags": model_data.get("tags", []),
|
||||||
"from_civitai": checkpoint_data.get("from_civitai", True),
|
"from_civitai": model_data.get("from_civitai", True),
|
||||||
"usage_count": checkpoint_data.get("usage_count", 0),
|
"usage_count": model_data.get("usage_count", 0),
|
||||||
"notes": checkpoint_data.get("notes", ""),
|
"notes": model_data.get("notes", ""),
|
||||||
"sub_type": sub_type,
|
"sub_type": sub_type,
|
||||||
"favorite": checkpoint_data.get("favorite", False),
|
"favorite": model_data.get("favorite", False),
|
||||||
"exclude": bool(checkpoint_data.get("exclude", False)),
|
"exclude": bool(model_data.get("exclude", False)),
|
||||||
"update_available": bool(checkpoint_data.get("update_available", False)),
|
"update_available": bool(model_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": checkpoint_data.get("auto_tags") or extract_auto_tags(checkpoint_data),
|
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||||
"version_count": checkpoint_data.get("version_count"),
|
"version_count": model_data.get("version_count"),
|
||||||
"hf_url": checkpoint_data.get("hf_url", ""),
|
"hf_url": model_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict[str, Any]:
|
||||||
"""Find Checkpoints with duplicate SHA256 hashes"""
|
"""Find Checkpoints with duplicate SHA256 hashes"""
|
||||||
return self.scanner._hash_index.get_duplicate_hashes()
|
return self.scanner._hash_index.get_duplicate_hashes()
|
||||||
|
|
||||||
def find_duplicate_filenames(self) -> Dict:
|
def find_duplicate_filenames(self) -> Dict[str, Any]:
|
||||||
"""Find Checkpoints with conflicting filenames"""
|
"""Find Checkpoints with conflicting filenames"""
|
||||||
return self.scanner._hash_index.get_duplicate_filenames()
|
return self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from typing import Optional, Dict, Tuple, List
|
from typing import Any, Optional, Dict, Tuple, List, cast
|
||||||
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
|
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
|
||||||
from .downloader import get_downloader
|
from .downloader import get_downloader
|
||||||
from .errors import RateLimitError
|
from .errors import RateLimitError
|
||||||
@@ -37,8 +41,8 @@ class CivArchiveClient:
|
|||||||
async def _request_json(
|
async def _request_json(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
params: Optional[Dict[str, str]] = None
|
params: Optional[Dict[str, Any]] = None
|
||||||
) -> Tuple[Optional[Dict], Optional[str]]:
|
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Call CivArchive API and return JSON payload"""
|
"""Call CivArchive API and return JSON payload"""
|
||||||
success, payload = await self._make_request(path, params=params)
|
success, payload = await self._make_request(path, params=params)
|
||||||
if not success:
|
if not success:
|
||||||
@@ -52,12 +56,12 @@ class CivArchiveClient:
|
|||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
*,
|
*,
|
||||||
params: Optional[Dict[str, str]] = None,
|
params: Optional[Dict[str, Any]] = None,
|
||||||
) -> Tuple[bool, Dict | str]:
|
) -> Tuple[bool, Dict[str, Any] | str]:
|
||||||
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
"""Wrapper around downloader.make_request that surfaces rate limits."""
|
||||||
|
|
||||||
downloader = await get_downloader()
|
downloader = await get_downloader()
|
||||||
kwargs: Dict[str, Dict[str, str]] = {}
|
kwargs: Dict[str, Dict[str, Any]] = {}
|
||||||
if params:
|
if params:
|
||||||
safe_params = {str(key): str(value) for key, value in params.items() if value is not None}
|
safe_params = {str(key): str(value) for key, value in params.items() if value is not None}
|
||||||
if safe_params:
|
if safe_params:
|
||||||
@@ -73,10 +77,11 @@ class CivArchiveClient:
|
|||||||
if payload.provider is None:
|
if payload.provider is None:
|
||||||
payload.provider = "civarchive_api"
|
payload.provider = "civarchive_api"
|
||||||
raise payload
|
raise payload
|
||||||
return success, payload
|
# RateLimitError is always raised above, so the returned payload is a dict or str.
|
||||||
|
return success, cast(Dict[str, Any] | str, payload)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_payload(payload: Dict) -> Dict:
|
def _normalize_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Unwrap CivArchive responses that wrap content under a data key"""
|
"""Unwrap CivArchive responses that wrap content under a data key"""
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -86,12 +91,12 @@ class CivArchiveClient:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _split_context(payload: Dict) -> Tuple[Dict, Dict, List[Dict]]:
|
def _split_context(payload: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any], List[Dict[str, Any]]]:
|
||||||
"""Separate version payload from surrounding model context"""
|
"""Separate version payload from surrounding model context"""
|
||||||
data = CivArchiveClient._normalize_payload(payload)
|
data = CivArchiveClient._normalize_payload(payload)
|
||||||
context: Dict = {}
|
context: Dict[str, Any] = {}
|
||||||
fallback_files: List[Dict] = []
|
fallback_files: List[Dict[str, Any]] = []
|
||||||
version: Dict = {}
|
version: Dict[str, Any] = {}
|
||||||
|
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
if key in {"version", "model"}:
|
if key in {"version", "model"}:
|
||||||
@@ -115,7 +120,7 @@ class CivArchiveClient:
|
|||||||
return context, version, fallback_files
|
return context, version, fallback_files
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _ensure_list(value) -> List:
|
def _ensure_list(value: Any) -> List[Any]:
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return value
|
return value
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -123,7 +128,7 @@ class CivArchiveClient:
|
|||||||
return [value]
|
return [value]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_model_info(context: Dict) -> Dict:
|
def _build_model_info(context: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
tags = context.get("tags")
|
tags = context.get("tags")
|
||||||
if not isinstance(tags, list):
|
if not isinstance(tags, list):
|
||||||
tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags])
|
tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags])
|
||||||
@@ -136,7 +141,7 @@ class CivArchiveClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_creator_info(context: Dict) -> Dict:
|
def _build_creator_info(context: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
username = context.get("creator_username") or context.get("username") or ""
|
username = context.get("creator_username") or context.get("username") or ""
|
||||||
image = context.get("creator_image") or context.get("creator_avatar") or ""
|
image = context.get("creator_image") or context.get("creator_avatar") or ""
|
||||||
creator: Dict[str, Optional[str]] = {
|
creator: Dict[str, Optional[str]] = {
|
||||||
@@ -150,7 +155,7 @@ class CivArchiveClient:
|
|||||||
return creator
|
return creator
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _transform_file_entry(file_data: Dict) -> Dict:
|
def _transform_file_entry(file_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
mirrors = file_data.get("mirrors") or []
|
mirrors = file_data.get("mirrors") or []
|
||||||
if not isinstance(mirrors, list):
|
if not isinstance(mirrors, list):
|
||||||
mirrors = [mirrors]
|
mirrors = [mirrors]
|
||||||
@@ -165,7 +170,7 @@ class CivArchiveClient:
|
|||||||
if not name and available_mirror:
|
if not name and available_mirror:
|
||||||
name = available_mirror.get("filename")
|
name = available_mirror.get("filename")
|
||||||
|
|
||||||
transformed: Dict = {
|
transformed: Dict[str, Any] = {
|
||||||
"id": file_data.get("id"),
|
"id": file_data.get("id"),
|
||||||
"sizeKB": file_data.get("sizeKB"),
|
"sizeKB": file_data.get("sizeKB"),
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -216,23 +221,23 @@ class CivArchiveClient:
|
|||||||
|
|
||||||
def _transform_files(
|
def _transform_files(
|
||||||
self,
|
self,
|
||||||
files: Optional[List[Dict]],
|
files: Optional[List[Dict[str, Any]]],
|
||||||
fallback_files: Optional[List[Dict]] = None
|
fallback_files: Optional[List[Dict[str, Any]]] = None
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
candidates: List[Dict] = []
|
candidates: List[Dict[str, Any]] = []
|
||||||
if isinstance(files, list) and files:
|
if isinstance(files, list) and files:
|
||||||
candidates = files
|
candidates = files
|
||||||
elif isinstance(fallback_files, list):
|
elif isinstance(fallback_files, list):
|
||||||
candidates = fallback_files
|
candidates = fallback_files
|
||||||
|
|
||||||
transformed_files: List[Dict] = []
|
transformed_files: List[Dict[str, Any]] = []
|
||||||
for file_data in candidates:
|
for file_data in candidates:
|
||||||
if isinstance(file_data, dict):
|
if isinstance(file_data, dict):
|
||||||
transformed_files.append(self._transform_file_entry(file_data))
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
|
||||||
# Sort: .safetensors first, .ckpt second, others last
|
# Sort: .safetensors first, .ckpt second, others last
|
||||||
# so the backend fallback (no file_params) prefers safetensors
|
# so the backend fallback (no file_params) prefers safetensors
|
||||||
def _sort_key(f: Dict) -> int:
|
def _sort_key(f: Dict[str, Any]) -> int:
|
||||||
fname = f.get("name") or ""
|
fname = f.get("name") or ""
|
||||||
if isinstance(fname, str):
|
if isinstance(fname, str):
|
||||||
lower = fname.lower()
|
lower = fname.lower()
|
||||||
@@ -247,10 +252,10 @@ class CivArchiveClient:
|
|||||||
|
|
||||||
def _transform_version(
|
def _transform_version(
|
||||||
self,
|
self,
|
||||||
context: Dict,
|
context: Dict[str, Any],
|
||||||
version: Dict,
|
version: Dict[str, Any],
|
||||||
fallback_files: Optional[List[Dict]] = None
|
fallback_files: Optional[List[Dict[str, Any]]] = None
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if not version:
|
if not version:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -291,7 +296,7 @@ class CivArchiveClient:
|
|||||||
|
|
||||||
return version_copy
|
return version_copy
|
||||||
|
|
||||||
async def _resolve_version_from_files(self, payload: Dict) -> Optional[Dict]:
|
async def _resolve_version_from_files(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Fallback to fetch version data when only file metadata is available"""
|
"""Fallback to fetch version data when only file metadata is available"""
|
||||||
data = self._normalize_payload(payload)
|
data = self._normalize_payload(payload)
|
||||||
files = data.get("files") or payload.get("files") or []
|
files = data.get("files") or payload.get("files") or []
|
||||||
@@ -323,7 +328,7 @@ class CivArchiveClient:
|
|||||||
return resolved
|
return resolved
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Find model by SHA256 hash value using CivArchive API"""
|
"""Find model by SHA256 hash value using CivArchive API"""
|
||||||
try:
|
try:
|
||||||
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
|
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
|
||||||
@@ -332,12 +337,12 @@ class CivArchiveClient:
|
|||||||
return None, "Model not found"
|
return None, "Model not found"
|
||||||
return None, error
|
return None, error
|
||||||
|
|
||||||
context, version_data, fallback_files = self._split_context(payload)
|
context, version_data, fallback_files = self._split_context(cast(Dict[str, Any], payload))
|
||||||
transformed = self._transform_version(context, version_data, fallback_files)
|
transformed = self._transform_version(context, version_data, fallback_files)
|
||||||
if transformed:
|
if transformed:
|
||||||
return transformed, None
|
return transformed, None
|
||||||
|
|
||||||
resolved = await self._resolve_version_from_files(payload)
|
resolved = await self._resolve_version_from_files(cast(Dict[str, Any], payload))
|
||||||
if resolved:
|
if resolved:
|
||||||
return resolved, None
|
return resolved, None
|
||||||
|
|
||||||
@@ -350,7 +355,7 @@ class CivArchiveClient:
|
|||||||
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
|
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
|
||||||
return None, str(e)
|
return None, str(e)
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get all versions of a model using CivArchive API"""
|
"""Get all versions of a model using CivArchive API"""
|
||||||
try:
|
try:
|
||||||
payload, error = await self._request_json(f"/models/{model_id}")
|
payload, error = await self._request_json(f"/models/{model_id}")
|
||||||
@@ -364,7 +369,7 @@ class CivArchiveClient:
|
|||||||
context, version_data, fallback_files = self._split_context(payload)
|
context, version_data, fallback_files = self._split_context(payload)
|
||||||
|
|
||||||
versions_meta = data.get("versions") or []
|
versions_meta = data.get("versions") or []
|
||||||
transformed_versions: List[Dict] = []
|
transformed_versions: List[Dict[str, Any]] = []
|
||||||
for meta in versions_meta:
|
for meta in versions_meta:
|
||||||
if not isinstance(meta, dict):
|
if not isinstance(meta, dict):
|
||||||
continue
|
continue
|
||||||
@@ -381,7 +386,7 @@ class CivArchiveClient:
|
|||||||
if primary_version:
|
if primary_version:
|
||||||
transformed_versions.insert(0, primary_version)
|
transformed_versions.insert(0, primary_version)
|
||||||
|
|
||||||
ordered_versions: List[Dict] = []
|
ordered_versions: List[Dict[str, Any]] = []
|
||||||
seen_ids = set()
|
seen_ids = set()
|
||||||
for version in transformed_versions:
|
for version in transformed_versions:
|
||||||
version_id = version.get("id")
|
version_id = version.get("id")
|
||||||
@@ -402,7 +407,7 @@ class CivArchiveClient:
|
|||||||
logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}")
|
logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: int | str | None = None, version_id: int | str | None = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get specific model version using CivArchive API
|
"""Get specific model version using CivArchive API
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -459,7 +464,7 @@ class CivArchiveClient:
|
|||||||
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}")
|
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
""" Fetch model version metadata using a known bogus model lookup
|
""" 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)
|
CivArchive lacks a direct version lookup API, this uses a workaround (which we handle in the main model request now)
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ class CivitaiBaseModelService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(result, str):
|
if isinstance(result, str):
|
||||||
data = json.loads(result)
|
data: Any = json.loads(result)
|
||||||
else:
|
else:
|
||||||
data = result
|
data = result
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Any, Optional, Dict, Tuple, List, Sequence
|
from typing import Any, Optional, Dict, Tuple, List, Sequence, cast
|
||||||
from .connectivity_guard import (
|
from .connectivity_guard import (
|
||||||
OFFLINE_FRIENDLY_MESSAGE,
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
is_expected_offline_error,
|
is_expected_offline_error,
|
||||||
@@ -58,7 +62,7 @@ class CivitaiClient:
|
|||||||
# Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent
|
# Uses OrderedDict with LRU eviction at MAX_CACHE_ENTRIES to prevent
|
||||||
# unbounded growth in long-running server processes.
|
# unbounded growth in long-running server processes.
|
||||||
self._version_info_cache: OrderedDict[
|
self._version_info_cache: OrderedDict[
|
||||||
str, Tuple[Optional[Dict], Optional[str]]
|
str, Tuple[Optional[Dict[str, Any]], Optional[str]]
|
||||||
] = OrderedDict()
|
] = OrderedDict()
|
||||||
self._MAX_CACHE_ENTRIES = 500
|
self._MAX_CACHE_ENTRIES = 500
|
||||||
|
|
||||||
@@ -72,7 +76,7 @@ class CivitaiClient:
|
|||||||
*,
|
*,
|
||||||
use_auth: bool = False,
|
use_auth: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Tuple[bool, Dict | str]:
|
) -> Tuple[bool, Dict[str, Any] | str]:
|
||||||
"""Wrapper around downloader.make_request that surfaces rate limits,
|
"""Wrapper around downloader.make_request that surfaces rate limits,
|
||||||
with retry for transient server errors (5xx, Cloudflare 524, network flakiness)."""
|
with retry for transient server errors (5xx, Cloudflare 524, network flakiness)."""
|
||||||
|
|
||||||
@@ -86,7 +90,8 @@ class CivitaiClient:
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
return True, result
|
# RateLimitError is raised below; a successful result is dict or str.
|
||||||
|
return True, cast(Dict[str, Any] | str, result)
|
||||||
|
|
||||||
if isinstance(result, RateLimitError):
|
if isinstance(result, RateLimitError):
|
||||||
if result.provider is None:
|
if result.provider is None:
|
||||||
@@ -126,7 +131,7 @@ class CivitaiClient:
|
|||||||
return False, "Unexpected error in _make_request"
|
return False, "Unexpected error in _make_request"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
|
def _remove_comfy_metadata(model_version: Optional[Dict[str, Any]]) -> None:
|
||||||
"""Remove Comfy-specific metadata from model version images."""
|
"""Remove Comfy-specific metadata from model version images."""
|
||||||
if not isinstance(model_version, dict):
|
if not isinstance(model_version, dict):
|
||||||
return
|
return
|
||||||
@@ -173,7 +178,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
async def get_model_by_hash(
|
async def get_model_by_hash(
|
||||||
self, model_hash: str
|
self, model_hash: str
|
||||||
) -> Tuple[Optional[Dict], Optional[str]]:
|
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
try:
|
try:
|
||||||
success, version = await self._make_request(
|
success, version = await self._make_request(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -220,7 +225,7 @@ class CivitaiClient:
|
|||||||
# Ensure directory exists
|
# Ensure directory exists
|
||||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
with open(save_path, "wb") as f:
|
with open(save_path, "wb") as f:
|
||||||
f.write(content)
|
f.write(content if isinstance(content, bytes) else content.encode("utf-8"))
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -275,7 +280,7 @@ class CivitaiClient:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get all versions of a model with local availability info"""
|
"""Get all versions of a model with local availability info"""
|
||||||
try:
|
try:
|
||||||
success, result = await self._make_request(
|
success, result = await self._make_request(
|
||||||
@@ -283,7 +288,7 @@ class CivitaiClient:
|
|||||||
f"{self.base_url}/models/{model_id}",
|
f"{self.base_url}/models/{model_id}",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success and isinstance(result, dict):
|
||||||
# Also return model type along with versions
|
# Also return model type along with versions
|
||||||
return {
|
return {
|
||||||
"modelVersions": result.get("modelVersions", []),
|
"modelVersions": result.get("modelVersions", []),
|
||||||
@@ -317,7 +322,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
async def get_model_versions_bulk(
|
async def get_model_versions_bulk(
|
||||||
self, model_ids: Sequence[int]
|
self, model_ids: Sequence[int]
|
||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict[str, Any]]]:
|
||||||
"""Fetch model metadata for multiple ids using the batch API."""
|
"""Fetch model metadata for multiple ids using the batch API."""
|
||||||
|
|
||||||
deduped: Dict[int, None] = {}
|
deduped: Dict[int, None] = {}
|
||||||
@@ -347,13 +352,13 @@ class CivitaiClient:
|
|||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
payload: Dict[int, Dict] = {}
|
payload: Dict[int, Dict[str, Any]] = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
model_id = item.get("id")
|
model_id = item.get("id")
|
||||||
try:
|
try:
|
||||||
normalized_id = int(model_id)
|
normalized_id = int(cast(Any, model_id))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
payload[normalized_id] = {
|
payload[normalized_id] = {
|
||||||
@@ -373,8 +378,8 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_version(
|
async def get_model_version(
|
||||||
self, model_id: int = None, version_id: int = None
|
self, model_id: int | None = None, version_id: int | None = None
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Get specific model version with additional metadata."""
|
"""Get specific model version with additional metadata."""
|
||||||
try:
|
try:
|
||||||
if model_id is None and version_id is not None:
|
if model_id is None and version_id is not None:
|
||||||
@@ -392,7 +397,7 @@ class CivitaiClient:
|
|||||||
logger.error(f"Error fetching model version: {e}")
|
logger.error(f"Error fetching model version: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict]:
|
async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict[str, Any]]:
|
||||||
version = await self._fetch_version_by_id(version_id)
|
version = await self._fetch_version_by_id(version_id)
|
||||||
if version is None:
|
if version is None:
|
||||||
return None
|
return None
|
||||||
@@ -411,7 +416,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
async def _get_version_with_model_id(
|
async def _get_version_with_model_id(
|
||||||
self, model_id: int, version_id: Optional[int]
|
self, model_id: int, version_id: Optional[int]
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
model_data = await self._fetch_model_data(model_id)
|
model_data = await self._fetch_model_data(model_id)
|
||||||
if not model_data:
|
if not model_data:
|
||||||
return None
|
return None
|
||||||
@@ -464,20 +469,20 @@ class CivitaiClient:
|
|||||||
self._remove_comfy_metadata(version)
|
self._remove_comfy_metadata(version)
|
||||||
return version
|
return version
|
||||||
|
|
||||||
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
|
async def _fetch_model_data(self, model_id: int) -> Optional[Dict[str, Any]]:
|
||||||
success, data = await self._make_request(
|
success, data = await self._make_request(
|
||||||
"GET",
|
"GET",
|
||||||
f"{self.base_url}/models/{model_id}",
|
f"{self.base_url}/models/{model_id}",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success and isinstance(data, dict):
|
||||||
return data
|
return data
|
||||||
if is_expected_offline_error(data):
|
if is_expected_offline_error(data):
|
||||||
return None
|
return None
|
||||||
logger.warning(f"Failed to fetch model data for model {model_id}")
|
logger.warning(f"Failed to fetch model data for model {model_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict]:
|
async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict[str, Any]]:
|
||||||
if version_id is None:
|
if version_id is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -486,7 +491,7 @@ class CivitaiClient:
|
|||||||
f"{self.base_url}/model-versions/{version_id}",
|
f"{self.base_url}/model-versions/{version_id}",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success and isinstance(version, dict):
|
||||||
return version
|
return version
|
||||||
if is_expected_offline_error(version):
|
if is_expected_offline_error(version):
|
||||||
return None
|
return None
|
||||||
@@ -494,7 +499,7 @@ class CivitaiClient:
|
|||||||
logger.warning(f"Failed to fetch version by id {version_id}")
|
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict]:
|
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
if not model_hash:
|
if not model_hash:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -503,7 +508,7 @@ class CivitaiClient:
|
|||||||
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
f"{self.base_url}/model-versions/by-hash/{model_hash}",
|
||||||
use_auth=True,
|
use_auth=True,
|
||||||
)
|
)
|
||||||
if success:
|
if success and isinstance(version, dict):
|
||||||
return version
|
return version
|
||||||
if is_expected_offline_error(version):
|
if is_expected_offline_error(version):
|
||||||
return None
|
return None
|
||||||
@@ -512,8 +517,8 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _select_target_version(
|
def _select_target_version(
|
||||||
self, model_data: Dict, model_id: int, version_id: Optional[int]
|
self, model_data: Dict[str, Any], model_id: int, version_id: Optional[int]
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
model_versions = model_data.get("modelVersions", [])
|
model_versions = model_data.get("modelVersions", [])
|
||||||
if not model_versions:
|
if not model_versions:
|
||||||
logger.warning(f"No model versions found for model {model_id}")
|
logger.warning(f"No model versions found for model {model_id}")
|
||||||
@@ -532,7 +537,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
return model_versions[0]
|
return model_versions[0]
|
||||||
|
|
||||||
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
|
def _extract_primary_model_hash(self, version_entry: Dict[str, Any]) -> Optional[str]:
|
||||||
for file_info in version_entry.get("files", []):
|
for file_info in version_entry.get("files", []):
|
||||||
if file_info.get("type") == "Model" and file_info.get("primary"):
|
if file_info.get("type") == "Model" and file_info.get("primary"):
|
||||||
hashes = file_info.get("hashes", {})
|
hashes = file_info.get("hashes", {})
|
||||||
@@ -542,8 +547,8 @@ class CivitaiClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _build_version_from_model_data(
|
def _build_version_from_model_data(
|
||||||
self, version_entry: Dict, model_id: int, model_data: Dict
|
self, version_entry: Dict[str, Any], model_id: int, model_data: Dict[str, Any]
|
||||||
) -> Dict:
|
) -> Dict[str, Any]:
|
||||||
version = copy.deepcopy(version_entry)
|
version = copy.deepcopy(version_entry)
|
||||||
version.pop("index", None)
|
version.pop("index", None)
|
||||||
version["modelId"] = model_id
|
version["modelId"] = model_id
|
||||||
@@ -555,7 +560,7 @@ class CivitaiClient:
|
|||||||
}
|
}
|
||||||
return version
|
return version
|
||||||
|
|
||||||
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None:
|
def _enrich_version_with_model_data(self, version: Dict[str, Any], model_data: Dict[str, Any]) -> None:
|
||||||
model_info = version.get("model")
|
model_info = version.get("model")
|
||||||
if not isinstance(model_info, dict):
|
if not isinstance(model_info, dict):
|
||||||
model_info = {}
|
model_info = {}
|
||||||
@@ -571,7 +576,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
async def get_model_version_info(
|
async def get_model_version_info(
|
||||||
self, version_id: str
|
self, version_id: str
|
||||||
) -> Tuple[Optional[Dict], Optional[str]]:
|
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Fetch model version metadata from Civitai
|
"""Fetch model version metadata from Civitai
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -596,7 +601,7 @@ class CivitaiClient:
|
|||||||
logger.debug("Resolving Civitai model version info: %s", url)
|
logger.debug("Resolving Civitai model version info: %s", url)
|
||||||
success, result = await self._make_request("GET", url, use_auth=True)
|
success, result = await self._make_request("GET", url, use_auth=True)
|
||||||
|
|
||||||
if success:
|
if success and isinstance(result, dict):
|
||||||
logger.debug("Successfully fetched model version info for: %s", version_id)
|
logger.debug("Successfully fetched model version info for: %s", version_id)
|
||||||
self._remove_comfy_metadata(result)
|
self._remove_comfy_metadata(result)
|
||||||
self._version_info_cache[version_id] = (result, None)
|
self._version_info_cache[version_id] = (result, None)
|
||||||
@@ -626,7 +631,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
async def get_image_info(
|
async def get_image_info(
|
||||||
self, image_id: str, source_url: str | None = None
|
self, image_id: str, source_url: str | None = None
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch image information from Civitai API
|
"""Fetch image information from Civitai API
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -659,7 +664,7 @@ class CivitaiClient:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if result and "items" in result and isinstance(result["items"], list):
|
if isinstance(result, dict) and "items" in result and isinstance(result["items"], list):
|
||||||
items = result["items"]
|
items = result["items"]
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -699,7 +704,7 @@ class CivitaiClient:
|
|||||||
|
|
||||||
async def get_model_versions_by_hashes(
|
async def get_model_versions_by_hashes(
|
||||||
self, hashes: List[str]
|
self, hashes: List[str]
|
||||||
) -> Optional[List[Dict]]:
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
|
"""Fetch full version details for up to 100 SHA256 hashes via the batch endpoint.
|
||||||
|
|
||||||
Uses POST /api/v1/model-versions/by-hash which returns full version
|
Uses POST /api/v1/model-versions/by-hash which returns full version
|
||||||
@@ -716,7 +721,7 @@ class CivitaiClient:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
BATCH_SIZE = 100
|
BATCH_SIZE = 100
|
||||||
all_versions: List[Dict] = []
|
all_versions: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for start in range(0, len(hashes), BATCH_SIZE):
|
for start in range(0, len(hashes), BATCH_SIZE):
|
||||||
batch = hashes[start : start + BATCH_SIZE]
|
batch = hashes[start : start + BATCH_SIZE]
|
||||||
@@ -736,7 +741,7 @@ class CivitaiClient:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(result, list):
|
if isinstance(result, list):
|
||||||
all_versions.extend(result)
|
all_versions.extend(cast(Any, result))
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Unexpected by-hash response type: %s", type(result)
|
"Unexpected by-hash response type: %s", type(result)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class DownloadCoordinator:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
ws_manager,
|
ws_manager,
|
||||||
download_manager_factory: Callable[[], Awaitable],
|
download_manager_factory: Callable[[], Awaitable[Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
self._ws_manager = ws_manager
|
self._ws_manager = ws_manager
|
||||||
self._download_manager_factory = download_manager_factory
|
self._download_manager_factory = download_manager_factory
|
||||||
|
|||||||
+113
-76
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -8,7 +12,7 @@ import zipfile
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, List, Optional, Set, Tuple
|
from typing import Any, Dict, List, Optional, Set, Tuple, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
@@ -18,7 +22,7 @@ from ..utils.constants import (
|
|||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
from ..utils.file_utils import calculate_sha256
|
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
from ..utils.utils import sanitize_folder_name
|
from ..utils.utils import sanitize_folder_name
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
@@ -121,7 +125,7 @@ class DownloadManager:
|
|||||||
"delay": 0,
|
"delay": 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except DownloadInProgressError:
|
except DownloadInProgressError: # pyright: ignore[reportPossiblyUnboundVariable]
|
||||||
logger.info(
|
logger.info(
|
||||||
"Skipping automatic example images download for %s; another example images download is already running",
|
"Skipping automatic example images download for %s; another example images download is already running",
|
||||||
model_hash,
|
model_hash,
|
||||||
@@ -170,7 +174,7 @@ class DownloadManager:
|
|||||||
logger.error("aria2 download failed for %s: %s", download_url, exc)
|
logger.error("aria2 download failed for %s: %s", download_url, exc)
|
||||||
return False, str(exc)
|
return False, str(exc)
|
||||||
|
|
||||||
download_kwargs = {
|
download_kwargs: Dict[str, Any] = {
|
||||||
"progress_callback": progress_callback,
|
"progress_callback": progress_callback,
|
||||||
"use_auth": use_auth,
|
"use_auth": use_auth,
|
||||||
}
|
}
|
||||||
@@ -204,16 +208,16 @@ class DownloadManager:
|
|||||||
|
|
||||||
async def download_from_civitai(
|
async def download_from_civitai(
|
||||||
self,
|
self,
|
||||||
model_id: int = None,
|
model_id: int | None = None,
|
||||||
model_version_id: int = None,
|
model_version_id: int | None = None,
|
||||||
save_dir: str = None,
|
save_dir: str | None = None,
|
||||||
relative_path: str = "",
|
relative_path: str = "",
|
||||||
progress_callback=None,
|
progress_callback=None,
|
||||||
use_default_paths: bool = False,
|
use_default_paths: bool = False,
|
||||||
download_id: str = None,
|
download_id: str | None = None,
|
||||||
source: str = None,
|
source: str | None = None,
|
||||||
file_params: Dict = None,
|
file_params: Dict[str, Any] | None = None,
|
||||||
) -> Dict:
|
) -> Dict[str, Any]:
|
||||||
"""Download model from Civitai with task tracking and concurrency control
|
"""Download model from Civitai with task tracking and concurrency control
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -309,14 +313,14 @@ class DownloadManager:
|
|||||||
async def _download_with_semaphore(
|
async def _download_with_semaphore(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
model_id: int,
|
model_id: int | None,
|
||||||
model_version_id: int,
|
model_version_id: int | None,
|
||||||
save_dir: str,
|
save_dir: str | None,
|
||||||
relative_path: str,
|
relative_path: str,
|
||||||
progress_callback=None,
|
progress_callback=None,
|
||||||
use_default_paths: bool = False,
|
use_default_paths: bool = False,
|
||||||
source: str = None,
|
source: str | None = None,
|
||||||
file_params: Dict = None,
|
file_params: Dict[str, Any] | None = None,
|
||||||
):
|
):
|
||||||
"""Execute download with semaphore to limit concurrency"""
|
"""Execute download with semaphore to limit concurrency"""
|
||||||
# Update status to waiting
|
# Update status to waiting
|
||||||
@@ -380,7 +384,8 @@ class DownloadManager:
|
|||||||
# Use original download implementation
|
# Use original download implementation
|
||||||
try:
|
try:
|
||||||
# Check for cancellation before starting
|
# Check for cancellation before starting
|
||||||
if asyncio.current_task().cancelled():
|
current_task = asyncio.current_task()
|
||||||
|
if current_task is not None and current_task.cancelled():
|
||||||
raise asyncio.CancelledError()
|
raise asyncio.CancelledError()
|
||||||
|
|
||||||
result = await self._execute_original_download(
|
result = await self._execute_original_download(
|
||||||
@@ -484,11 +489,11 @@ class DownloadManager:
|
|||||||
# Schedule cleanup of download record after delay
|
# Schedule cleanup of download record after delay
|
||||||
asyncio.create_task(self._cleanup_download_record(task_id))
|
asyncio.create_task(self._cleanup_download_record(task_id))
|
||||||
|
|
||||||
def _start_background_download_task(self, download_id: str, coroutine) -> asyncio.Task:
|
def _start_background_download_task(self, download_id: str, coroutine) -> asyncio.Task[Any]:
|
||||||
task = asyncio.create_task(coroutine)
|
task = asyncio.create_task(coroutine)
|
||||||
self._download_tasks[download_id] = task
|
self._download_tasks[download_id] = task
|
||||||
|
|
||||||
def _cleanup_done_task(done_task: asyncio.Task) -> None:
|
def _cleanup_done_task(done_task: asyncio.Task[Any]) -> None:
|
||||||
current_task = self._download_tasks.get(download_id)
|
current_task = self._download_tasks.get(download_id)
|
||||||
if current_task is done_task:
|
if current_task is done_task:
|
||||||
self._download_tasks.pop(download_id, None)
|
self._download_tasks.pop(download_id, None)
|
||||||
@@ -530,7 +535,7 @@ class DownloadManager:
|
|||||||
async def _cleanup_cancelled_download_files(
|
async def _cleanup_cancelled_download_files(
|
||||||
self,
|
self,
|
||||||
download_id: str,
|
download_id: str,
|
||||||
download_info: Optional[Dict],
|
download_info: Optional[Dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
target_files = set()
|
target_files = set()
|
||||||
persisted = await self._aria2_state_store.get(download_id)
|
persisted = await self._aria2_state_store.get(download_id)
|
||||||
@@ -603,13 +608,13 @@ class DownloadManager:
|
|||||||
self,
|
self,
|
||||||
download_id: str,
|
download_id: str,
|
||||||
*,
|
*,
|
||||||
extra: Optional[Dict] = None,
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
info = self._active_downloads.get(download_id)
|
info = self._active_downloads.get(download_id)
|
||||||
if not info:
|
if not info:
|
||||||
return
|
return
|
||||||
|
|
||||||
payload = {
|
payload: Dict[str, Any] = {
|
||||||
"download_id": download_id,
|
"download_id": download_id,
|
||||||
"model_id": info.get("model_id"),
|
"model_id": info.get("model_id"),
|
||||||
"model_version_id": info.get("model_version_id"),
|
"model_version_id": info.get("model_version_id"),
|
||||||
@@ -631,7 +636,7 @@ class DownloadManager:
|
|||||||
|
|
||||||
await self._aria2_state_store.upsert(download_id, payload)
|
await self._aria2_state_store.upsert(download_id, payload)
|
||||||
|
|
||||||
def _build_restored_download_info(self, record: Dict, save_path: str) -> Dict:
|
def _build_restored_download_info(self, record: Dict[str, Any], save_path: str) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"model_id": record.get("model_id"),
|
"model_id": record.get("model_id"),
|
||||||
"model_version_id": record.get("model_version_id"),
|
"model_version_id": record.get("model_version_id"),
|
||||||
@@ -653,8 +658,8 @@ class DownloadManager:
|
|||||||
|
|
||||||
def _is_same_aria2_download_request(
|
def _is_same_aria2_download_request(
|
||||||
self,
|
self,
|
||||||
current_info: Optional[Dict],
|
current_info: Optional[Dict[str, Any]],
|
||||||
persisted_record: Dict,
|
persisted_record: Dict[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not isinstance(current_info, dict):
|
if not isinstance(current_info, dict):
|
||||||
return False
|
return False
|
||||||
@@ -666,13 +671,15 @@ class DownloadManager:
|
|||||||
|
|
||||||
return current_version_id == persisted_version_id
|
return current_version_id == persisted_version_id
|
||||||
|
|
||||||
def _build_download_urls_from_file_info(self, file_info: Dict, source: str = None) -> List[str]:
|
def _build_download_urls_from_file_info(self, file_info: Dict[str, Any], source: str | None = None) -> List[str]:
|
||||||
mirrors = file_info.get("mirrors") or []
|
mirrors = file_info.get("mirrors") or []
|
||||||
download_urls: List[str] = []
|
download_urls: List[str] = []
|
||||||
if mirrors:
|
if mirrors:
|
||||||
for mirror in mirrors:
|
for mirror in mirrors:
|
||||||
if mirror.get("deletedAt") is None and mirror.get("url"):
|
if mirror.get("deletedAt") is None and mirror.get("url"):
|
||||||
download_urls.append(normalize_civitai_download_url(mirror["url"]))
|
normalized_url = normalize_civitai_download_url(mirror["url"])
|
||||||
|
if normalized_url:
|
||||||
|
download_urls.append(normalized_url)
|
||||||
|
|
||||||
if source == "civarchive" and len(download_urls) > 1:
|
if source == "civarchive" and len(download_urls) > 1:
|
||||||
civitai_urls = [
|
civitai_urls = [
|
||||||
@@ -688,7 +695,9 @@ class DownloadManager:
|
|||||||
if not download_urls:
|
if not download_urls:
|
||||||
download_url = file_info.get("downloadUrl")
|
download_url = file_info.get("downloadUrl")
|
||||||
if download_url:
|
if download_url:
|
||||||
download_urls.append(normalize_civitai_download_url(download_url))
|
normalized_url = normalize_civitai_download_url(download_url)
|
||||||
|
if normalized_url:
|
||||||
|
download_urls.append(normalized_url)
|
||||||
|
|
||||||
return download_urls
|
return download_urls
|
||||||
|
|
||||||
@@ -696,8 +705,8 @@ class DownloadManager:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
model_type: str,
|
model_type: str,
|
||||||
version_info: Dict,
|
version_info: Dict[str, Any],
|
||||||
file_info: Dict,
|
file_info: Dict[str, Any],
|
||||||
save_path: str,
|
save_path: str,
|
||||||
):
|
):
|
||||||
if model_type == "checkpoint":
|
if model_type == "checkpoint":
|
||||||
@@ -706,7 +715,7 @@ class DownloadManager:
|
|||||||
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
|
|
||||||
def _resolve_save_path_from_persisted_record(self, record: Dict) -> Optional[str]:
|
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
|
||||||
save_path = record.get("save_path") or record.get("file_path")
|
save_path = record.get("save_path") or record.get("file_path")
|
||||||
if isinstance(save_path, str) and save_path:
|
if isinstance(save_path, str) and save_path:
|
||||||
return os.path.abspath(save_path)
|
return os.path.abspath(save_path)
|
||||||
@@ -728,7 +737,7 @@ class DownloadManager:
|
|||||||
|
|
||||||
return os.path.abspath(os.path.join(save_dir, file_name))
|
return os.path.abspath(os.path.join(save_dir, file_name))
|
||||||
|
|
||||||
async def _resume_restored_aria2_download(self, download_id: str, record: Dict) -> Dict:
|
async def _resume_restored_aria2_download(self, download_id: str, record: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
if download_id in self._active_downloads:
|
if download_id in self._active_downloads:
|
||||||
self._active_downloads[download_id]["status"] = "downloading"
|
self._active_downloads[download_id]["status"] = "downloading"
|
||||||
@@ -842,7 +851,7 @@ class DownloadManager:
|
|||||||
self,
|
self,
|
||||||
previous_download_id: str,
|
previous_download_id: str,
|
||||||
new_download_id: str,
|
new_download_id: str,
|
||||||
persisted_record: Dict,
|
persisted_record: Dict[str, Any],
|
||||||
save_path: str,
|
save_path: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
aria2_downloader = await get_aria2_downloader()
|
aria2_downloader = await get_aria2_downloader()
|
||||||
@@ -938,7 +947,7 @@ class DownloadManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
status_payload = None
|
status_payload = None
|
||||||
|
|
||||||
if status_payload is not None:
|
if status_payload is not None and isinstance(gid, str):
|
||||||
remote_status = status_payload.get("status", "")
|
remote_status = status_payload.get("status", "")
|
||||||
if remote_status in {"active", "waiting", "paused"}:
|
if remote_status in {"active", "waiting", "paused"}:
|
||||||
await aria2_downloader.restore_transfer(download_id, gid, save_path)
|
await aria2_downloader.restore_transfer(download_id, gid, save_path)
|
||||||
@@ -1115,17 +1124,17 @@ class DownloadManager:
|
|||||||
|
|
||||||
async def _execute_original_download(
|
async def _execute_original_download(
|
||||||
self,
|
self,
|
||||||
model_id,
|
model_id: int | None,
|
||||||
model_version_id,
|
model_version_id: int | None,
|
||||||
save_dir,
|
save_dir: str | None,
|
||||||
relative_path,
|
relative_path: str,
|
||||||
progress_callback,
|
progress_callback,
|
||||||
use_default_paths,
|
use_default_paths: bool,
|
||||||
download_id=None,
|
download_id: str | None = None,
|
||||||
transfer_backend="python",
|
transfer_backend: str = "python",
|
||||||
source=None,
|
source: str | None = None,
|
||||||
file_params=None,
|
file_params: Dict[str, Any] | None = None,
|
||||||
):
|
) -> Dict[str, Any]:
|
||||||
"""Wrapper for original download_from_civitai implementation"""
|
"""Wrapper for original download_from_civitai implementation"""
|
||||||
try:
|
try:
|
||||||
# Check if model version already exists in library
|
# Check if model version already exists in library
|
||||||
@@ -1172,7 +1181,7 @@ class DownloadManager:
|
|||||||
|
|
||||||
# Get version info based on the provided identifier
|
# Get version info based on the provided identifier
|
||||||
version_info = await metadata_provider.get_model_version(
|
version_info = await metadata_provider.get_model_version(
|
||||||
model_id, model_version_id
|
cast(int, model_id), cast(int, model_version_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not version_info:
|
if not version_info:
|
||||||
@@ -1183,7 +1192,7 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
metadata_provider = await get_default_metadata_provider()
|
metadata_provider = await get_default_metadata_provider()
|
||||||
version_info = await metadata_provider.get_model_version(
|
version_info = await metadata_provider.get_model_version(
|
||||||
model_id, model_version_id
|
cast(int, model_id), cast(int, model_version_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not version_info:
|
if not version_info:
|
||||||
@@ -1388,6 +1397,8 @@ class DownloadManager:
|
|||||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||||
|
|
||||||
# Update save directory with relative path if provided
|
# Update save directory with relative path if provided
|
||||||
|
if not save_dir:
|
||||||
|
return {"success": False, "error": "No save directory specified"}
|
||||||
if relative_path:
|
if relative_path:
|
||||||
base_save_dir = save_dir
|
base_save_dir = save_dir
|
||||||
save_dir = os.path.join(save_dir, relative_path)
|
save_dir = os.path.join(save_dir, relative_path)
|
||||||
@@ -1561,6 +1572,11 @@ class DownloadManager:
|
|||||||
version_info, file_info, save_path
|
version_info, file_info, save_path
|
||||||
)
|
)
|
||||||
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f'Unsupported model type "{model_type}"',
|
||||||
|
}
|
||||||
|
|
||||||
# 6. Start download process
|
# 6. Start download process
|
||||||
if transfer_backend == "aria2" and download_id:
|
if transfer_backend == "aria2" and download_id:
|
||||||
@@ -1580,7 +1596,7 @@ class DownloadManager:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
execute_kwargs = {
|
execute_kwargs: Dict[str, Any] = {
|
||||||
"download_urls": download_urls,
|
"download_urls": download_urls,
|
||||||
"save_dir": save_dir,
|
"save_dir": save_dir,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
@@ -1627,7 +1643,8 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# If early_access_msg exists and download failed, replace error message
|
# If early_access_msg exists and download failed, replace error message
|
||||||
if "early_access_msg" in locals() and not result.get("success", False):
|
early_access_msg = locals().get("early_access_msg")
|
||||||
|
if early_access_msg and not result.get("success", False):
|
||||||
result["error"] = early_access_msg
|
result["error"] = early_access_msg
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1652,7 +1669,7 @@ class DownloadManager:
|
|||||||
self,
|
self,
|
||||||
model_type: str,
|
model_type: str,
|
||||||
model_id_value,
|
model_id_value,
|
||||||
version_info: Dict,
|
version_info: Dict[str, Any],
|
||||||
fallback_version_id=None,
|
fallback_version_id=None,
|
||||||
file_path: str | None = None,
|
file_path: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1683,8 +1700,8 @@ class DownloadManager:
|
|||||||
try:
|
try:
|
||||||
await history_service.mark_downloaded(
|
await history_service.mark_downloaded(
|
||||||
model_type,
|
model_type,
|
||||||
int(version_id),
|
int(cast(Any, version_id)),
|
||||||
model_id=int(resolved_model_id) if resolved_model_id is not None else None,
|
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
|
||||||
source="download",
|
source="download",
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
)
|
)
|
||||||
@@ -1701,7 +1718,7 @@ class DownloadManager:
|
|||||||
self,
|
self,
|
||||||
model_type: str,
|
model_type: str,
|
||||||
model_id_value,
|
model_id_value,
|
||||||
version_info: Dict,
|
version_info: Dict[str, Any],
|
||||||
fallback_version_id=None,
|
fallback_version_id=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Ensure update tracking reflects a newly downloaded version."""
|
"""Ensure update tracking reflects a newly downloaded version."""
|
||||||
@@ -1725,7 +1742,7 @@ class DownloadManager:
|
|||||||
if isinstance(model_info, dict):
|
if isinstance(model_info, dict):
|
||||||
resolved_model_id = model_info.get("id")
|
resolved_model_id = model_info.get("id")
|
||||||
try:
|
try:
|
||||||
resolved_model_id = int(resolved_model_id)
|
resolved_model_id = int(cast(Any, resolved_model_id))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Skipping update sync; invalid model id: %s", resolved_model_id
|
"Skipping update sync; invalid model id: %s", resolved_model_id
|
||||||
@@ -1736,7 +1753,7 @@ class DownloadManager:
|
|||||||
if version_id is None:
|
if version_id is None:
|
||||||
version_id = fallback_version_id
|
version_id = fallback_version_id
|
||||||
try:
|
try:
|
||||||
version_id = int(version_id)
|
version_id = int(cast(Any, version_id))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Skipping update sync; invalid version id for model %s: %s",
|
"Skipping update sync; invalid version id for model %s: %s",
|
||||||
@@ -1773,7 +1790,7 @@ class DownloadManager:
|
|||||||
for entry in local_versions or []:
|
for entry in local_versions or []:
|
||||||
vid = entry.get("versionId")
|
vid = entry.get("versionId")
|
||||||
try:
|
try:
|
||||||
version_ids.add(int(vid))
|
version_ids.add(int(cast(Any, vid)))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1795,7 +1812,7 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_relative_path(
|
def _calculate_relative_path(
|
||||||
self, version_info: Dict, model_type: str = "lora"
|
self, version_info: Dict[str, Any], model_type: str = "lora"
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Calculate relative path using template from settings
|
"""Calculate relative path using template from settings
|
||||||
|
|
||||||
@@ -1871,21 +1888,22 @@ class DownloadManager:
|
|||||||
download_urls: List[str],
|
download_urls: List[str],
|
||||||
save_dir: str,
|
save_dir: str,
|
||||||
metadata,
|
metadata,
|
||||||
version_info: Dict,
|
version_info: Dict[str, Any],
|
||||||
relative_path: str,
|
relative_path: str,
|
||||||
progress_callback=None,
|
progress_callback=None,
|
||||||
model_type: str = "lora",
|
model_type: str = "lora",
|
||||||
download_id: str = None,
|
download_id: str | None = None,
|
||||||
transfer_backend: Optional[str] = None,
|
transfer_backend: Optional[str] = None,
|
||||||
) -> Dict:
|
) -> Dict[str, Any]:
|
||||||
"""Execute the actual download process including preview images and model files"""
|
"""Execute the actual download process including preview images and model files"""
|
||||||
metadata_entries: List = []
|
metadata_entries: List[Any] = []
|
||||||
metadata_files_for_cleanup: List[str] = []
|
metadata_files_for_cleanup: List[str] = []
|
||||||
extracted_paths: List[str] = []
|
extracted_paths: List[str] = []
|
||||||
metadata_path = ""
|
metadata_path = ""
|
||||||
preview_targets: List[str] = []
|
preview_targets: List[str] = []
|
||||||
preview_path: str | None = None
|
preview_path: str | None = None
|
||||||
preview_nsfw_level = 0
|
preview_nsfw_level = 0
|
||||||
|
save_path: str | None = None
|
||||||
transfer_backend = (transfer_backend or self._get_model_download_backend()).lower()
|
transfer_backend = (transfer_backend or self._get_model_download_backend()).lower()
|
||||||
try:
|
try:
|
||||||
resolved, save_path = await self._resolve_download_target_path(
|
resolved, save_path = await self._resolve_download_target_path(
|
||||||
@@ -1933,9 +1951,9 @@ class DownloadManager:
|
|||||||
mature_threshold=mature_threshold,
|
mature_threshold=mature_threshold,
|
||||||
)
|
)
|
||||||
|
|
||||||
preview_url = selected_image.get("url") if selected_image else None
|
preview_url = cast(Optional[str], selected_image.get("url")) if selected_image else None
|
||||||
media_type = (
|
media_type = (
|
||||||
(selected_image.get("type") or "").lower() if selected_image else ""
|
cast(str, selected_image.get("type") or "").lower() if selected_image else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
def _extension_from_url(url: str, fallback: str) -> str:
|
def _extension_from_url(url: str, fallback: str) -> str:
|
||||||
@@ -1959,9 +1977,10 @@ class DownloadManager:
|
|||||||
preview_url, media_type="video"
|
preview_url, media_type="video"
|
||||||
)
|
)
|
||||||
attempt_urls: List[str] = []
|
attempt_urls: List[str] = []
|
||||||
if rewritten:
|
if rewritten and rewritten_url:
|
||||||
attempt_urls.append(rewritten_url)
|
attempt_urls.append(rewritten_url)
|
||||||
attempt_urls.append(preview_url)
|
if preview_url:
|
||||||
|
attempt_urls.append(preview_url)
|
||||||
|
|
||||||
seen_attempts = set()
|
seen_attempts = set()
|
||||||
for attempt in attempt_urls:
|
for attempt in attempt_urls:
|
||||||
@@ -1978,7 +1997,7 @@ class DownloadManager:
|
|||||||
rewritten_url, rewritten = rewrite_preview_url(
|
rewritten_url, rewritten = rewrite_preview_url(
|
||||||
preview_url, media_type="image"
|
preview_url, media_type="image"
|
||||||
)
|
)
|
||||||
if rewritten:
|
if rewritten and rewritten_url:
|
||||||
preview_ext = _extension_from_url(preview_url, ".png")
|
preview_ext = _extension_from_url(preview_url, ".png")
|
||||||
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
preview_path = os.path.splitext(save_path)[0] + preview_ext
|
||||||
success, _ = await downloader.download_file(
|
success, _ = await downloader.download_file(
|
||||||
@@ -2004,7 +2023,9 @@ class DownloadManager:
|
|||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
with open(temp_path, "wb") as temp_file_handle:
|
with open(temp_path, "wb") as temp_file_handle:
|
||||||
temp_file_handle.write(content)
|
temp_file_handle.write(
|
||||||
|
content if isinstance(content, bytes) else content.encode("utf-8")
|
||||||
|
)
|
||||||
preview_path = (
|
preview_path = (
|
||||||
os.path.splitext(save_path)[0] + ".webp"
|
os.path.splitext(save_path)[0] + ".webp"
|
||||||
)
|
)
|
||||||
@@ -2056,6 +2077,8 @@ class DownloadManager:
|
|||||||
last_error = None
|
last_error = None
|
||||||
for download_url in download_urls:
|
for download_url in download_urls:
|
||||||
download_url = normalize_civitai_download_url(download_url)
|
download_url = normalize_civitai_download_url(download_url)
|
||||||
|
if download_url is None:
|
||||||
|
continue
|
||||||
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
use_auth = download_url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
||||||
if transfer_backend == "aria2" and download_id:
|
if transfer_backend == "aria2" and download_id:
|
||||||
await self._persist_aria2_state(
|
await self._persist_aria2_state(
|
||||||
@@ -2160,6 +2183,10 @@ class DownloadManager:
|
|||||||
"error": f"Zip archive does not contain any supported model files ({supported_text})",
|
"error": f"Zip archive does not contain any supported model files ({supported_text})",
|
||||||
}
|
}
|
||||||
actual_file_paths = extracted_paths
|
actual_file_paths = extracted_paths
|
||||||
|
# The archive entry's AutoV3 (if any) describes the zip itself,
|
||||||
|
# not the extracted models; clear it so per-file header
|
||||||
|
# resolution applies to every extracted model.
|
||||||
|
metadata.autov3 = None
|
||||||
try:
|
try:
|
||||||
os.remove(save_path)
|
os.remove(save_path)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -2235,7 +2262,7 @@ class DownloadManager:
|
|||||||
entry, normalized_file_path, adjust_root
|
entry, normalized_file_path, adjust_root
|
||||||
)
|
)
|
||||||
if adjusted_entry is not None:
|
if adjusted_entry is not None:
|
||||||
entry = adjusted_entry
|
entry = cast(Any, adjusted_entry)
|
||||||
metadata_entries[index] = entry
|
metadata_entries[index] = entry
|
||||||
|
|
||||||
metadata_file_path = (
|
metadata_file_path = (
|
||||||
@@ -2355,11 +2382,11 @@ class DownloadManager:
|
|||||||
|
|
||||||
async def _build_metadata_entries(
|
async def _build_metadata_entries(
|
||||||
self, base_metadata, file_paths: List[str]
|
self, base_metadata, file_paths: List[str]
|
||||||
) -> List:
|
) -> List[Any]:
|
||||||
if not file_paths:
|
if not file_paths:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
entries: List = []
|
entries: List[Any] = []
|
||||||
for index, file_path in enumerate(file_paths):
|
for index, file_path in enumerate(file_paths):
|
||||||
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
|
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
|
||||||
# Update file paths without modifying size and modified timestamps
|
# Update file paths without modifying size and modified timestamps
|
||||||
@@ -2374,6 +2401,16 @@ class DownloadManager:
|
|||||||
sha256 = await calculate_sha256(file_path)
|
sha256 = await calculate_sha256(file_path)
|
||||||
if sha256:
|
if sha256:
|
||||||
entry.sha256 = sha256.lower()
|
entry.sha256 = sha256.lower()
|
||||||
|
# AutoV3: the Civitai-reported value for the downloaded file (set
|
||||||
|
# by from_civitai_info) takes precedence. Only the un-checked
|
||||||
|
# state (None) triggers a header read; '' (checked-unavailable)
|
||||||
|
# is never re-read, honoring the three-state contract so rows
|
||||||
|
# marked at download time stay untouched by later passes.
|
||||||
|
if entry.autov3 is None:
|
||||||
|
autov3 = await asyncio.get_running_loop().run_in_executor(
|
||||||
|
None, calculate_autov3, file_path
|
||||||
|
)
|
||||||
|
entry.autov3 = (autov3 or "").lower()
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
@@ -2392,7 +2429,7 @@ class DownloadManager:
|
|||||||
return destination
|
return destination
|
||||||
|
|
||||||
def _distribute_preview_to_entries(
|
def _distribute_preview_to_entries(
|
||||||
self, preview_path: str, entries: List
|
self, preview_path: str, entries: List[Any]
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
if not preview_path or not entries:
|
if not preview_path or not entries:
|
||||||
return []
|
return []
|
||||||
@@ -2451,7 +2488,7 @@ class DownloadManager:
|
|||||||
progress_callback, normalized_snapshot, rounded_progress
|
progress_callback, normalized_snapshot, rounded_progress
|
||||||
)
|
)
|
||||||
|
|
||||||
async def cancel_download(self, download_id: str) -> Dict:
|
async def cancel_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
"""Cancel an active download by download_id
|
"""Cancel an active download by download_id
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -2533,7 +2570,7 @@ class DownloadManager:
|
|||||||
self._download_tasks.pop(download_id, None)
|
self._download_tasks.pop(download_id, None)
|
||||||
await self._aria2_state_store.remove(download_id)
|
await self._aria2_state_store.remove(download_id)
|
||||||
|
|
||||||
async def skip_download(self, download_id: str) -> Dict:
|
async def skip_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
"""Skip a download while preserving all partial files on disk.
|
"""Skip a download while preserving all partial files on disk.
|
||||||
|
|
||||||
Removes all in-memory tracking (asyncio task, semaphore, active/pause
|
Removes all in-memory tracking (asyncio task, semaphore, active/pause
|
||||||
@@ -2616,7 +2653,7 @@ class DownloadManager:
|
|||||||
# Preserve aria2 state store entry so the partial download
|
# Preserve aria2 state store entry so the partial download
|
||||||
# info survives restarts and can be resumed later
|
# info survives restarts and can be resumed later
|
||||||
|
|
||||||
async def pause_download(self, download_id: str) -> Dict:
|
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
"""Pause an active download without losing progress."""
|
"""Pause an active download without losing progress."""
|
||||||
|
|
||||||
await self._restore_persisted_downloads()
|
await self._restore_persisted_downloads()
|
||||||
@@ -2663,7 +2700,7 @@ class DownloadManager:
|
|||||||
|
|
||||||
return {"success": True, "message": "Download paused successfully"}
|
return {"success": True, "message": "Download paused successfully"}
|
||||||
|
|
||||||
async def resume_download(self, download_id: str) -> Dict:
|
async def resume_download(self, download_id: str) -> Dict[str, Any]:
|
||||||
"""Resume a previously paused download."""
|
"""Resume a previously paused download."""
|
||||||
|
|
||||||
await self._restore_persisted_downloads()
|
await self._restore_persisted_downloads()
|
||||||
@@ -2680,7 +2717,7 @@ class DownloadManager:
|
|||||||
self._pause_events[download_id] = pause_control
|
self._pause_events[download_id] = pause_control
|
||||||
self._active_downloads[download_id] = self._build_restored_download_info(
|
self._active_downloads[download_id] = self._build_restored_download_info(
|
||||||
persisted,
|
persisted,
|
||||||
os.path.abspath(save_path),
|
os.path.abspath(cast(str, save_path)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if pause_control.is_set():
|
if pause_control.is_set():
|
||||||
@@ -2807,7 +2844,7 @@ class DownloadManager:
|
|||||||
elif asyncio.iscoroutine(result):
|
elif asyncio.iscoroutine(result):
|
||||||
await result
|
await result
|
||||||
|
|
||||||
async def get_active_downloads(self) -> Dict:
|
async def get_active_downloads(self) -> Dict[str, Any]:
|
||||||
"""Get information about all active downloads
|
"""Get information about all active downloads
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
"""
|
"""
|
||||||
Unified download manager for all HTTP/HTTPS downloads in the application.
|
Unified download manager for all HTTP/HTTPS downloads in the application.
|
||||||
|
|
||||||
@@ -20,7 +24,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
|
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable, Any, cast
|
||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
from .connectivity_guard import (
|
from .connectivity_guard import (
|
||||||
OFFLINE_COOLDOWN_ERROR,
|
OFFLINE_COOLDOWN_ERROR,
|
||||||
@@ -204,6 +208,7 @@ class Downloader:
|
|||||||
# Double check after acquiring lock
|
# Double check after acquiring lock
|
||||||
if self._session is None or self._should_refresh_session():
|
if self._session is None or self._should_refresh_session():
|
||||||
await self._create_session()
|
await self._create_session()
|
||||||
|
assert self._session is not None
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -231,7 +236,7 @@ class Downloader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
timeout_value = float(raw_value)
|
timeout_value = float(cast(Any, raw_value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
timeout_value = default_timeout
|
timeout_value = default_timeout
|
||||||
|
|
||||||
@@ -243,7 +248,7 @@ class Downloader:
|
|||||||
raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES")
|
raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
retries = int(raw_value)
|
retries = int(cast(Any, raw_value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
retries = default_retries
|
retries = default_retries
|
||||||
|
|
||||||
@@ -320,7 +325,7 @@ class Downloader:
|
|||||||
# CA coverage across different Python environments (especially
|
# CA coverage across different Python environments (especially
|
||||||
# embedded/compatibility Python builds).
|
# embedded/compatibility Python builds).
|
||||||
try:
|
try:
|
||||||
import certifi # type: ignore[import-untyped]
|
import certifi # pyright: ignore[reportMissingTypeStubs]
|
||||||
|
|
||||||
ca_path = certifi.where()
|
ca_path = certifi.where()
|
||||||
ssl_context = ssl.create_default_context(cafile=ca_path)
|
ssl_context = ssl.create_default_context(cafile=ca_path)
|
||||||
@@ -330,7 +335,7 @@ class Downloader:
|
|||||||
logger.debug("SSL: certifi unavailable; using system default CA bundle")
|
logger.debug("SSL: certifi unavailable; using system default CA bundle")
|
||||||
|
|
||||||
# Optimize TCP connection parameters
|
# Optimize TCP connection parameters
|
||||||
connector_kwargs = dict(
|
connector_kwargs: Dict[str, Any] = dict(
|
||||||
ssl=ssl_context,
|
ssl=ssl_context,
|
||||||
limit=8, # Concurrent connections
|
limit=8, # Concurrent connections
|
||||||
ttl_dns_cache=300, # DNS cache timeout
|
ttl_dns_cache=300, # DNS cache timeout
|
||||||
@@ -890,7 +895,7 @@ class Downloader:
|
|||||||
use_auth: bool = False,
|
use_auth: bool = False,
|
||||||
custom_headers: Optional[Dict[str, str]] = None,
|
custom_headers: Optional[Dict[str, str]] = None,
|
||||||
return_headers: bool = False,
|
return_headers: bool = False,
|
||||||
) -> Tuple[bool, Union[bytes, str], Optional[Dict]]:
|
) -> Tuple[bool, Union[bytes, str], Optional[Dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Download a file to memory (for small files like preview images)
|
Download a file to memory (for small files like preview images)
|
||||||
|
|
||||||
@@ -976,7 +981,7 @@ class Downloader:
|
|||||||
url: str,
|
url: str,
|
||||||
use_auth: bool = False,
|
use_auth: bool = False,
|
||||||
custom_headers: Optional[Dict[str, str]] = None,
|
custom_headers: Optional[Dict[str, str]] = None,
|
||||||
) -> Tuple[bool, Union[Dict, str]]:
|
) -> Tuple[bool, Union[Dict[str, Any], str]]:
|
||||||
"""
|
"""
|
||||||
Get response headers without downloading the full content
|
Get response headers without downloading the full content
|
||||||
|
|
||||||
@@ -1036,7 +1041,7 @@ class Downloader:
|
|||||||
use_auth: bool = False,
|
use_auth: bool = False,
|
||||||
custom_headers: Optional[Dict[str, str]] = None,
|
custom_headers: Optional[Dict[str, str]] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> Tuple[bool, Union[Dict, str]]:
|
) -> Tuple[bool, Union[Dict[str, Any], str, RateLimitError]]:
|
||||||
"""
|
"""
|
||||||
Make a generic HTTP request and return JSON response
|
Make a generic HTTP request and return JSON response
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class EmbeddingScanner(ModelScanner):
|
|||||||
roots.extend(config.embeddings_roots or [])
|
roots.extend(config.embeddings_roots or [])
|
||||||
roots.extend(config.extra_embeddings_roots or [])
|
roots.extend(config.extra_embeddings_roots or [])
|
||||||
# Remove duplicates while preserving order
|
# Remove duplicates while preserving order
|
||||||
seen: set = set()
|
seen: set[str] = set()
|
||||||
unique_roots: List[str] = []
|
unique_roots: List[str] = []
|
||||||
for root in roots:
|
for root in roots:
|
||||||
if root and root not in seen:
|
if root and root not in seen:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .auto_tag_service import extract_auto_tags
|
from .auto_tag_service import extract_auto_tags
|
||||||
@@ -21,58 +21,58 @@ class EmbeddingService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, embedding_data: Dict) -> Optional[Dict]:
|
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Format Embedding data for API response.
|
"""Format Embedding data for API response.
|
||||||
|
|
||||||
Returns None when the entry is missing critical fields (corrupted cache
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
row), so the handler layer can filter it out. See issue #730.
|
row), so the handler layer can filter it out. See issue #730.
|
||||||
"""
|
"""
|
||||||
# Guard against corrupted cache entries missing critical fields
|
# Guard against corrupted cache entries missing critical fields
|
||||||
file_path = embedding_data.get("file_path")
|
file_path = model_data.get("file_path")
|
||||||
if not file_path or not isinstance(file_path, str):
|
if not file_path or not isinstance(file_path, str):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Skipping corrupted embedding entry (missing file_path): %s",
|
"Skipping corrupted embedding entry (missing file_path): %s",
|
||||||
embedding_data.get("file_name", "<unknown>"),
|
model_data.get("file_name", "<unknown>"),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get sub_type from cache entry (new canonical field)
|
# Get sub_type from cache entry (new canonical field)
|
||||||
sub_type = embedding_data.get("sub_type", "embedding")
|
sub_type = model_data.get("sub_type", "embedding")
|
||||||
|
|
||||||
file_name = embedding_data.get("file_name") or ""
|
file_name = model_data.get("file_name") or ""
|
||||||
model_name = embedding_data.get("model_name") or file_name
|
model_name = model_data.get("model_name") or file_name
|
||||||
folder = embedding_data.get("folder") or ""
|
folder = model_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
"file_name": file_name,
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(embedding_data.get("preview_url", "")),
|
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
|
||||||
"preview_nsfw_level": embedding_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": embedding_data.get("base_model", ""),
|
"base_model": model_data.get("base_model", ""),
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
"sha256": embedding_data.get("sha256", ""),
|
"sha256": model_data.get("sha256", ""),
|
||||||
"file_path": file_path.replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": embedding_data.get("size", 0),
|
"file_size": model_data.get("size", 0),
|
||||||
"modified": embedding_data.get("modified", ""),
|
"modified": model_data.get("modified", ""),
|
||||||
"tags": embedding_data.get("tags", []),
|
"tags": model_data.get("tags", []),
|
||||||
"from_civitai": embedding_data.get("from_civitai", True),
|
"from_civitai": model_data.get("from_civitai", True),
|
||||||
# "usage_count": embedding_data.get("usage_count", 0), # TODO: Enable when embedding usage tracking is implemented
|
# "usage_count": model_data.get("usage_count", 0), # TODO: Enable when embedding usage tracking is implemented
|
||||||
"notes": embedding_data.get("notes", ""),
|
"notes": model_data.get("notes", ""),
|
||||||
"sub_type": sub_type,
|
"sub_type": sub_type,
|
||||||
"favorite": embedding_data.get("favorite", False),
|
"favorite": model_data.get("favorite", False),
|
||||||
"exclude": bool(embedding_data.get("exclude", False)),
|
"exclude": bool(model_data.get("exclude", False)),
|
||||||
"update_available": bool(embedding_data.get("update_available", False)),
|
"update_available": bool(model_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
|
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
|
||||||
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True),
|
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
|
||||||
"auto_tags": embedding_data.get("auto_tags") or extract_auto_tags(embedding_data),
|
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||||
"version_count": embedding_data.get("version_count"),
|
"version_count": model_data.get("version_count"),
|
||||||
"hf_url": embedding_data.get("hf_url", ""),
|
"hf_url": model_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict[str, Any]:
|
||||||
"""Find Embeddings with duplicate SHA256 hashes"""
|
"""Find Embeddings with duplicate SHA256 hashes"""
|
||||||
return self.scanner._hash_index.get_duplicate_hashes()
|
return self.scanner._hash_index.get_duplicate_hashes()
|
||||||
|
|
||||||
def find_duplicate_filenames(self) -> Dict:
|
def find_duplicate_filenames(self) -> Dict[str, Any]:
|
||||||
"""Find Embeddings with conflicting filenames"""
|
"""Find Embeddings with conflicting filenames"""
|
||||||
return self.scanner._hash_index.get_duplicate_filenames()
|
return self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class CleanupResult:
|
|||||||
def to_dict(self) -> Dict[str, object]:
|
def to_dict(self) -> Dict[str, object]:
|
||||||
"""Convert the dataclass to a serialisable dictionary."""
|
"""Convert the dataclass to a serialisable dictionary."""
|
||||||
|
|
||||||
data = {
|
data: Dict[str, object] = {
|
||||||
"success": self.success,
|
"success": self.success,
|
||||||
"checked_folders": self.checked_folders,
|
"checked_folders": self.checked_folders,
|
||||||
"moved_empty_folders": self.moved_empty_folders,
|
"moved_empty_folders": self.moved_empty_folders,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from ..utils.models import LoraMetadata
|
from ..utils.models import LoraMetadata
|
||||||
from ..config import config
|
|
||||||
from .model_scanner import ModelScanner
|
from .model_scanner import ModelScanner
|
||||||
from .model_hash_index import ModelHashIndex # Changed from LoraHashIndex to ModelHashIndex
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -15,8 +17,10 @@ class LoraScanner(ModelScanner):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Define supported file extensions
|
# Define supported file extensions
|
||||||
file_extensions = {'.safetensors'}
|
file_extensions = {'.safetensors'}
|
||||||
|
|
||||||
# Initialize parent class with ModelHashIndex
|
# Initialize parent class with ModelHashIndex
|
||||||
|
from .model_hash_index import ModelHashIndex
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
model_type="lora",
|
model_type="lora",
|
||||||
model_class=LoraMetadata,
|
model_class=LoraMetadata,
|
||||||
@@ -26,11 +30,13 @@ class LoraScanner(ModelScanner):
|
|||||||
|
|
||||||
def get_model_roots(self) -> List[str]:
|
def get_model_roots(self) -> List[str]:
|
||||||
"""Get lora root directories (including extra paths)"""
|
"""Get lora root directories (including extra paths)"""
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
roots: List[str] = []
|
roots: List[str] = []
|
||||||
roots.extend(config.loras_roots or [])
|
roots.extend(config.loras_roots or [])
|
||||||
roots.extend(config.extra_loras_roots or [])
|
roots.extend(config.extra_loras_roots or [])
|
||||||
# Remove duplicates while preserving order
|
# Remove duplicates while preserving order
|
||||||
seen: set = set()
|
seen: set[str] = set()
|
||||||
unique_roots: List[str] = []
|
unique_roots: List[str] = []
|
||||||
for root in roots:
|
for root in roots:
|
||||||
if root and root not in seen:
|
if root and root not in seen:
|
||||||
@@ -68,8 +74,12 @@ class LoraScanner(ModelScanner):
|
|||||||
test_hash = next(iter(self._hash_index._hash_to_path.keys()))
|
test_hash = next(iter(self._hash_index._hash_to_path.keys()))
|
||||||
test_path = self._hash_index.get_path(test_hash)
|
test_path = self._hash_index.get_path(test_hash)
|
||||||
logger.debug(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}")
|
logger.debug(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}")
|
||||||
|
if test_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
# Also test reverse lookup
|
# Also test reverse lookup
|
||||||
test_hash_result = self._hash_index.get_hash(test_path)
|
test_hash_result = self._hash_index.get_hash(test_path)
|
||||||
|
if test_hash_result is None:
|
||||||
|
return
|
||||||
logger.debug(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n")
|
logger.debug(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n")
|
||||||
|
|
||||||
|
|||||||
+41
-41
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .base_model_service import BaseModelService
|
from .base_model_service import BaseModelService
|
||||||
from .model_query import resolve_sub_type
|
from .model_query import resolve_sub_type
|
||||||
@@ -24,7 +24,7 @@ class LoraService(BaseModelService):
|
|||||||
"""
|
"""
|
||||||
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
|
||||||
|
|
||||||
async def format_response(self, lora_data: Dict) -> Optional[Dict]:
|
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Format LoRA data for API response.
|
"""Format LoRA data for API response.
|
||||||
|
|
||||||
Returns None when the entry is missing critical fields (corrupted cache
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
@@ -32,56 +32,56 @@ class LoraService(BaseModelService):
|
|||||||
whole listing request. See issue #730.
|
whole listing request. See issue #730.
|
||||||
"""
|
"""
|
||||||
# Guard against corrupted cache entries missing critical fields
|
# Guard against corrupted cache entries missing critical fields
|
||||||
file_path = lora_data.get("file_path")
|
file_path = model_data.get("file_path")
|
||||||
if not file_path or not isinstance(file_path, str):
|
if not file_path or not isinstance(file_path, str):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Skipping corrupted LoRA entry (missing file_path): %s",
|
"Skipping corrupted LoRA entry (missing file_path): %s",
|
||||||
lora_data.get("file_name", "<unknown>"),
|
model_data.get("file_name", "<unknown>"),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||||
# Normalize to lowercase for consistent API responses
|
# Normalize to lowercase for consistent API responses
|
||||||
sub_type = resolve_sub_type(lora_data).lower()
|
sub_type = resolve_sub_type(model_data).lower()
|
||||||
|
|
||||||
file_name = lora_data.get("file_name") or ""
|
file_name = model_data.get("file_name") or ""
|
||||||
model_name = lora_data.get("model_name") or file_name
|
model_name = model_data.get("model_name") or file_name
|
||||||
folder = lora_data.get("folder") or ""
|
folder = model_data.get("folder") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
"file_name": file_name,
|
"file_name": file_name,
|
||||||
"preview_url": config.get_preview_static_url(
|
"preview_url": config.get_preview_static_url(
|
||||||
lora_data.get("preview_url", "")
|
model_data.get("preview_url", "")
|
||||||
),
|
),
|
||||||
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
|
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
|
||||||
"base_model": lora_data.get("base_model", ""),
|
"base_model": model_data.get("base_model", ""),
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
"sha256": lora_data.get("sha256", ""),
|
"sha256": model_data.get("sha256", ""),
|
||||||
"file_path": file_path.replace(os.sep, "/"),
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
"file_size": lora_data.get("size", 0),
|
"file_size": model_data.get("size", 0),
|
||||||
"modified": lora_data.get("modified", ""),
|
"modified": model_data.get("modified", ""),
|
||||||
"tags": lora_data.get("tags", []),
|
"tags": model_data.get("tags", []),
|
||||||
"from_civitai": lora_data.get("from_civitai", True),
|
"from_civitai": model_data.get("from_civitai", True),
|
||||||
"usage_count": lora_data.get("usage_count", 0),
|
"usage_count": model_data.get("usage_count", 0),
|
||||||
"usage_tips": lora_data.get("usage_tips", ""),
|
"usage_tips": model_data.get("usage_tips", ""),
|
||||||
"notes": lora_data.get("notes", ""),
|
"notes": model_data.get("notes", ""),
|
||||||
"favorite": lora_data.get("favorite", False),
|
"favorite": model_data.get("favorite", False),
|
||||||
"exclude": bool(lora_data.get("exclude", False)),
|
"exclude": bool(model_data.get("exclude", False)),
|
||||||
"update_available": bool(lora_data.get("update_available", False)),
|
"update_available": bool(model_data.get("update_available", False)),
|
||||||
"skip_metadata_refresh": bool(
|
"skip_metadata_refresh": bool(
|
||||||
lora_data.get("skip_metadata_refresh", False)
|
model_data.get("skip_metadata_refresh", False)
|
||||||
),
|
),
|
||||||
"sub_type": sub_type,
|
"sub_type": sub_type,
|
||||||
"civitai": self.filter_civitai_data(
|
"civitai": self.filter_civitai_data(
|
||||||
lora_data.get("civitai", {}), minimal=True
|
model_data.get("civitai", {}), minimal=True
|
||||||
),
|
),
|
||||||
"auto_tags": lora_data.get("auto_tags") or extract_auto_tags(lora_data),
|
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||||
"version_count": lora_data.get("version_count"),
|
"version_count": model_data.get("version_count"),
|
||||||
"hf_url": lora_data.get("hf_url", ""),
|
"hf_url": model_data.get("hf_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
|
||||||
"""Apply LoRA-specific filters"""
|
"""Apply LoRA-specific filters"""
|
||||||
# Handle first_letter filter for LoRAs
|
# Handle first_letter filter for LoRAs
|
||||||
first_letter = kwargs.get("first_letter")
|
first_letter = kwargs.get("first_letter")
|
||||||
@@ -152,7 +152,7 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
|
def _filter_by_first_letter(self, data: List[Dict[str, Any]], letter: str) -> List[Dict[str, Any]]:
|
||||||
"""Filter data by first letter of model name
|
"""Filter data by first letter of model name
|
||||||
|
|
||||||
Special handling:
|
Special handling:
|
||||||
@@ -307,7 +307,7 @@ class LoraService(BaseModelService):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_recommended_strength_from_lora_data(lora_data: Dict) -> Optional[float]:
|
def get_recommended_strength_from_lora_data(lora_data: Dict[str, Any]) -> Optional[float]:
|
||||||
"""Parse usage_tips JSON and extract recommended model strength."""
|
"""Parse usage_tips JSON and extract recommended model strength."""
|
||||||
try:
|
try:
|
||||||
usage_tips = lora_data.get("usage_tips", "")
|
usage_tips = lora_data.get("usage_tips", "")
|
||||||
@@ -320,7 +320,7 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_recommended_clip_strength_from_lora_data(
|
def get_recommended_clip_strength_from_lora_data(
|
||||||
lora_data: Dict,
|
lora_data: Dict[str, Any],
|
||||||
) -> Optional[float]:
|
) -> Optional[float]:
|
||||||
"""Parse usage_tips JSON and extract recommended clip strength."""
|
"""Parse usage_tips JSON and extract recommended clip strength."""
|
||||||
try:
|
try:
|
||||||
@@ -332,7 +332,7 @@ class LoraService(BaseModelService):
|
|||||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_lora_metadata_by_filename(self, filename: str) -> Optional[Dict]:
|
async def get_lora_metadata_by_filename(self, filename: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Return cached raw metadata for a LoRA matching the given filename."""
|
"""Return cached raw metadata for a LoRA matching the given filename."""
|
||||||
cache = await self.scanner.get_cached_data(force_refresh=False)
|
cache = await self.scanner.get_cached_data(force_refresh=False)
|
||||||
|
|
||||||
@@ -357,11 +357,11 @@ class LoraService(BaseModelService):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def find_duplicate_hashes(self) -> Dict:
|
def find_duplicate_hashes(self) -> Dict[str, Any]:
|
||||||
"""Find LoRAs with duplicate SHA256 hashes"""
|
"""Find LoRAs with duplicate SHA256 hashes"""
|
||||||
return self.scanner._hash_index.get_duplicate_hashes()
|
return self.scanner._hash_index.get_duplicate_hashes()
|
||||||
|
|
||||||
def find_duplicate_filenames(self) -> Dict:
|
def find_duplicate_filenames(self) -> Dict[str, Any]:
|
||||||
"""Find LoRAs with conflicting filenames"""
|
"""Find LoRAs with conflicting filenames"""
|
||||||
return self.scanner._hash_index.get_duplicate_filenames()
|
return self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|
||||||
@@ -373,8 +373,8 @@ class LoraService(BaseModelService):
|
|||||||
use_same_clip_strength: bool = True,
|
use_same_clip_strength: bool = True,
|
||||||
clip_strength_min: float = 0.0,
|
clip_strength_min: float = 0.0,
|
||||||
clip_strength_max: float = 1.0,
|
clip_strength_max: float = 1.0,
|
||||||
locked_loras: Optional[List[Dict]] = None,
|
locked_loras: Optional[List[Dict[str, Any]]] = None,
|
||||||
pool_config: Optional[Dict] = None,
|
pool_config: Optional[Dict[str, Any]] = None,
|
||||||
count_mode: str = "fixed",
|
count_mode: str = "fixed",
|
||||||
count_min: int = 3,
|
count_min: int = 3,
|
||||||
count_max: int = 7,
|
count_max: int = 7,
|
||||||
@@ -382,7 +382,7 @@ class LoraService(BaseModelService):
|
|||||||
recommended_strength_scale_min: float = 0.5,
|
recommended_strength_scale_min: float = 0.5,
|
||||||
recommended_strength_scale_max: float = 1.0,
|
recommended_strength_scale_max: float = 1.0,
|
||||||
seed: Optional[int] = None,
|
seed: Optional[int] = None,
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get random LoRAs with specified strength ranges.
|
Get random LoRAs with specified strength ranges.
|
||||||
|
|
||||||
@@ -513,8 +513,8 @@ class LoraService(BaseModelService):
|
|||||||
return result_loras
|
return result_loras
|
||||||
|
|
||||||
async def _apply_pool_filters(
|
async def _apply_pool_filters(
|
||||||
self, available_loras: List[Dict], pool_config: Dict
|
self, available_loras: List[Dict[str, Any]], pool_config: Dict[str, Any]
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Apply pool_config filters to available LoRAs.
|
Apply pool_config filters to available LoRAs.
|
||||||
|
|
||||||
@@ -671,8 +671,8 @@ class LoraService(BaseModelService):
|
|||||||
return available_loras
|
return available_loras
|
||||||
|
|
||||||
async def get_cycler_list(
|
async def get_cycler_list(
|
||||||
self, pool_config: Optional[Dict] = None, sort_by: str = "filename"
|
self, pool_config: Optional[Dict[str, Any]] = None, sort_by: str = "filename"
|
||||||
) -> List[Dict]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get filtered and sorted LoRA list for cycling.
|
Get filtered and sorted LoRA list for cycling.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from .model_metadata_provider import (
|
from .model_metadata_provider import (
|
||||||
@@ -170,7 +174,7 @@ def _wrap_provider_with_rate_limit(provider_name: str | None, provider: ModelMet
|
|||||||
return RateLimitRetryingProvider(provider, label=provider_name)
|
return RateLimitRetryingProvider(provider, label=provider_name)
|
||||||
|
|
||||||
|
|
||||||
async def get_metadata_provider(provider_name: str = None):
|
async def get_metadata_provider(provider_name: str | None = None):
|
||||||
"""Get a specific metadata provider or default provider with rate-limit handling."""
|
"""Get a specific metadata provider or default provider with rate-limit handling."""
|
||||||
|
|
||||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||||
|
|||||||
@@ -6,25 +6,26 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
from typing import Any, Awaitable, Callable, Dict, Iterable, Optional, Protocol
|
||||||
|
|
||||||
from ..services.settings_manager import SettingsManager
|
from ..services.settings_manager import SettingsManager
|
||||||
from ..utils.civitai_utils import resolve_license_payload
|
from ..utils.civitai_utils import resolve_license_payload
|
||||||
from ..utils.model_utils import determine_base_model
|
from ..utils.model_utils import determine_base_model
|
||||||
|
from ..utils.models import autov3_from_civitai_files
|
||||||
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
|
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
|
||||||
from .errors import RateLimitError
|
from .errors import RateLimitError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MetadataProviderProtocol:
|
class MetadataProviderProtocol(Protocol):
|
||||||
"""Subset of metadata provider interface consumed by the sync service."""
|
"""Subset of metadata provider interface consumed by the sync service."""
|
||||||
|
|
||||||
async def get_model_by_hash(self, sha256: str) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
...
|
...
|
||||||
|
|
||||||
async def get_model_version(
|
async def get_model_version(
|
||||||
self, model_id: int, model_version_id: Optional[int]
|
self, model_id: Any = None, version_id: Any = None
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -38,8 +39,8 @@ class MetadataSyncService:
|
|||||||
metadata_manager,
|
metadata_manager,
|
||||||
preview_service,
|
preview_service,
|
||||||
settings: SettingsManager,
|
settings: SettingsManager,
|
||||||
default_metadata_provider_factory: Callable[[], Awaitable[MetadataProviderProtocol]],
|
default_metadata_provider_factory: Callable[..., Awaitable[MetadataProviderProtocol]],
|
||||||
metadata_provider_selector: Callable[[str], Awaitable[MetadataProviderProtocol]],
|
metadata_provider_selector: Callable[..., Awaitable[MetadataProviderProtocol]],
|
||||||
) -> None:
|
) -> None:
|
||||||
self._metadata_manager = metadata_manager
|
self._metadata_manager = metadata_manager
|
||||||
self._preview_service = preview_service
|
self._preview_service = preview_service
|
||||||
@@ -152,6 +153,18 @@ class MetadataSyncService:
|
|||||||
civitai_metadata.get("baseModel")
|
civitai_metadata.get("baseModel")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Civitai-first AutoV3 propagation: the freshly fetched version
|
||||||
|
# metadata may report an AutoV3 for the file whose SHA256 matches the
|
||||||
|
# local model. Persist it now so recipe matching sees it immediately —
|
||||||
|
# no full rescan or restart required (the header is never re-read to
|
||||||
|
# upgrade the checked-unavailable '' state).
|
||||||
|
sha256_value = (local_metadata.get("sha256") or "").lower()
|
||||||
|
civitai_autov3 = autov3_from_civitai_files(
|
||||||
|
local_metadata.get("civitai"), sha256_value
|
||||||
|
)
|
||||||
|
if civitai_autov3:
|
||||||
|
local_metadata["autov3"] = civitai_autov3
|
||||||
|
|
||||||
await self._preview_service.ensure_preview_for_metadata(
|
await self._preview_service.ensure_preview_for_metadata(
|
||||||
metadata_path, local_metadata, civitai_metadata.get("images", [])
|
metadata_path, local_metadata, civitai_metadata.get("images", [])
|
||||||
)
|
)
|
||||||
@@ -479,7 +492,7 @@ class MetadataSyncService:
|
|||||||
if not file_paths:
|
if not file_paths:
|
||||||
raise ValueError("No file paths provided for verification")
|
raise ValueError("No file paths provided for verification")
|
||||||
|
|
||||||
results = {
|
results: Dict[str, Any] = {
|
||||||
"verified_as_duplicates": True,
|
"verified_as_duplicates": True,
|
||||||
"mismatched_files": [],
|
"mismatched_files": [],
|
||||||
"new_hash_map": {},
|
"new_hash_map": {},
|
||||||
|
|||||||
+21
-16
@@ -31,17 +31,22 @@ DISPLAY_NAME_MODES = {"model_name", "file_name"}
|
|||||||
class ModelCache:
|
class ModelCache:
|
||||||
"""Cache structure for model data with extensible sorting."""
|
"""Cache structure for model data with extensible sorting."""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict[str, Any]]
|
||||||
folders: List[str]
|
folders: List[str]
|
||||||
version_index: Dict[int, Dict] = field(default_factory=dict)
|
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||||
name_display_mode: str = "model_name"
|
name_display_mode: str = "model_name"
|
||||||
|
_lock: Any = field(init=False, repr=False, default=None)
|
||||||
|
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
||||||
|
_last_sort: Tuple[Optional[str], str, Optional[str]] = field(
|
||||||
|
init=False, repr=False, default=(None, "asc", None)
|
||||||
|
)
|
||||||
|
_last_sorted_data: List[Dict[str, Any]] = field(
|
||||||
|
init=False, repr=False, default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
|
||||||
self._last_sort: Tuple[Optional[str], str, Optional[str]] = (None, "asc", None)
|
|
||||||
self._last_sorted_data: List[Dict] = []
|
|
||||||
self._normalize_raw_data()
|
self._normalize_raw_data()
|
||||||
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
|
||||||
# Default sort on init
|
# Default sort on init
|
||||||
@@ -64,7 +69,7 @@ class ModelCache:
|
|||||||
return ""
|
return ""
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
def _normalize_item(self, item: Dict) -> None:
|
def _normalize_item(self, item: Dict[str, Any]) -> None:
|
||||||
"""Ensure core metadata fields are present and string typed."""
|
"""Ensure core metadata fields are present and string typed."""
|
||||||
|
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
@@ -80,7 +85,7 @@ class ModelCache:
|
|||||||
for item in self.raw_data:
|
for item in self.raw_data:
|
||||||
self._normalize_item(item)
|
self._normalize_item(item)
|
||||||
|
|
||||||
def _get_display_name(self, item: Dict) -> str:
|
def _get_display_name(self, item: Dict[str, Any]) -> str:
|
||||||
"""Return the value used for name-based sorting based on display settings."""
|
"""Return the value used for name-based sorting based on display settings."""
|
||||||
|
|
||||||
if self.name_display_mode == "file_name":
|
if self.name_display_mode == "file_name":
|
||||||
@@ -114,7 +119,7 @@ class ModelCache:
|
|||||||
for item in self.raw_data:
|
for item in self.raw_data:
|
||||||
self.add_to_version_index(item)
|
self.add_to_version_index(item)
|
||||||
|
|
||||||
def add_to_version_index(self, item: Dict) -> None:
|
def add_to_version_index(self, item: Dict[str, Any]) -> None:
|
||||||
"""Register a cache item in the version/model indexes if possible."""
|
"""Register a cache item in the version/model indexes if possible."""
|
||||||
|
|
||||||
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||||
@@ -143,7 +148,7 @@ class ModelCache:
|
|||||||
else:
|
else:
|
||||||
versions.append(descriptor)
|
versions.append(descriptor)
|
||||||
|
|
||||||
def remove_from_version_index(self, item: Dict) -> None:
|
def remove_from_version_index(self, item: Dict[str, Any]) -> None:
|
||||||
"""Remove a cache item from the version/model indexes if present."""
|
"""Remove a cache item from the version/model indexes if present."""
|
||||||
|
|
||||||
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
civitai_data = item.get('civitai') if isinstance(item, dict) else None
|
||||||
@@ -177,7 +182,7 @@ class ModelCache:
|
|||||||
|
|
||||||
def _build_version_descriptor(
|
def _build_version_descriptor(
|
||||||
self,
|
self,
|
||||||
item: Dict,
|
item: Dict[str, Any],
|
||||||
civitai_data: Dict[str, Any],
|
civitai_data: Dict[str, Any],
|
||||||
version_id: int,
|
version_id: int,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
@@ -204,8 +209,8 @@ class ModelCache:
|
|||||||
async def resort(self):
|
async def resort(self):
|
||||||
"""Resort cached data according to last sort mode if set"""
|
"""Resort cached data according to last sort mode if set"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if self._last_sort[0] is not None:
|
sort_key, order, seed = self._last_sort
|
||||||
sort_key, order, seed = self._last_sort
|
if sort_key is not None:
|
||||||
sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||||
self._last_sorted_data = sorted_data
|
self._last_sorted_data = sorted_data
|
||||||
# Update folder list
|
# Update folder list
|
||||||
@@ -219,7 +224,7 @@ class ModelCache:
|
|||||||
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||||
self.rebuild_version_index()
|
self.rebuild_version_index()
|
||||||
|
|
||||||
def _sort_data(self, data: List[Dict], sort_key: str, order: str, seed: Optional[str] = None) -> List[Dict]:
|
def _sort_data(self, data: List[Dict[str, Any]], sort_key: str, order: str, seed: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""Sort data by sort_key and order"""
|
"""Sort data by sort_key and order"""
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
reverse = (order == 'desc')
|
reverse = (order == 'desc')
|
||||||
@@ -293,7 +298,7 @@ class ModelCache:
|
|||||||
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
|
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc', seed: Optional[str] = None) -> List[Dict]:
|
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc', seed: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""Get sorted data by sort_key and order, using cache if possible"""
|
"""Get sorted data by sort_key and order, using cache if possible"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
cache_key = (sort_key, order, seed)
|
cache_key = (sort_key, order, seed)
|
||||||
@@ -321,8 +326,8 @@ class ModelCache:
|
|||||||
|
|
||||||
self.name_display_mode = normalized
|
self.name_display_mode = normalized
|
||||||
|
|
||||||
if self._last_sort[0] == 'name':
|
sort_key, order, seed = self._last_sort
|
||||||
sort_key, order, seed = self._last_sort
|
if sort_key == 'name':
|
||||||
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order, seed)
|
||||||
|
|
||||||
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class AutoOrganizeResult:
|
|||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert result to dictionary"""
|
"""Convert result to dictionary"""
|
||||||
result = {
|
result: Dict[str, Any] = {
|
||||||
'success': self.status != 'error',
|
'success': self.status != 'error',
|
||||||
'status': self.status,
|
'status': self.status,
|
||||||
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
||||||
@@ -418,6 +418,8 @@ class ModelFileService:
|
|||||||
"""Calculate the target directory for a model"""
|
"""Calculate the target directory for a model"""
|
||||||
if is_flat_structure:
|
if is_flat_structure:
|
||||||
file_path = model.get('file_path')
|
file_path = model.get('file_path')
|
||||||
|
if not isinstance(file_path, str):
|
||||||
|
return None
|
||||||
current_dir = os.path.dirname(file_path)
|
current_dir = os.path.dirname(file_path)
|
||||||
|
|
||||||
# Check if already in root directory
|
# Check if already in root directory
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ class ModelHashIndex:
|
|||||||
self._hash_to_path: Dict[str, str] = {}
|
self._hash_to_path: Dict[str, str] = {}
|
||||||
self._filename_to_hash: Dict[str, str] = {}
|
self._filename_to_hash: Dict[str, str] = {}
|
||||||
self._autov2_to_path: Dict[str, str] = {}
|
self._autov2_to_path: Dict[str, str] = {}
|
||||||
|
self._autov3_to_path: Dict[str, str] = {}
|
||||||
# New data structures for tracking duplicates
|
# New data structures for tracking duplicates
|
||||||
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
||||||
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
||||||
|
|
||||||
def add_entry(self, sha256: str, file_path: str) -> None:
|
def add_entry(self, sha256: str, file_path: str, autov3: Optional[str] = None) -> None:
|
||||||
"""Add or update hash index entry"""
|
"""Add or update hash index entry"""
|
||||||
if not sha256 or not file_path:
|
if not sha256 or not file_path:
|
||||||
return
|
return
|
||||||
@@ -33,9 +34,14 @@ class ModelHashIndex:
|
|||||||
self._duplicate_hashes.setdefault(sha256, []).append(file_path)
|
self._duplicate_hashes.setdefault(sha256, []).append(file_path)
|
||||||
|
|
||||||
# Track duplicates by filename - FIXED LOGIC
|
# Track duplicates by filename - FIXED LOGIC
|
||||||
|
is_re_registration = False
|
||||||
|
existing_hash: Optional[str] = None
|
||||||
if filename in self._filename_to_hash:
|
if filename in self._filename_to_hash:
|
||||||
existing_hash = self._filename_to_hash[filename]
|
existing_hash = self._filename_to_hash[filename]
|
||||||
existing_path = self._hash_to_path.get(existing_hash)
|
existing_path = self._hash_to_path.get(existing_hash)
|
||||||
|
# Same path registered again (e.g. a file replaced in place with
|
||||||
|
# new content) — used below to drop its stale autov3 mapping.
|
||||||
|
is_re_registration = existing_path == file_path
|
||||||
|
|
||||||
# If this is a different file with the same filename
|
# If this is a different file with the same filename
|
||||||
if existing_path and existing_path != file_path:
|
if existing_path and existing_path != file_path:
|
||||||
@@ -67,12 +73,36 @@ class ModelHashIndex:
|
|||||||
# AutoV2 = first 10 chars of SHA256
|
# AutoV2 = first 10 chars of SHA256
|
||||||
if len(sha256) >= 10:
|
if len(sha256) >= 10:
|
||||||
self._autov2_to_path[sha256[:10]] = file_path
|
self._autov2_to_path[sha256[:10]] = file_path
|
||||||
|
# AutoV3 is an independent hash (not derived from SHA256), stored as-is.
|
||||||
|
# Drop stale mappings for a path when it is re-registered with a NEW
|
||||||
|
# sha256 (file replaced in place) or with an explicit new autov3 value
|
||||||
|
# (correction). Re-registering the SAME file with the same sha256 and
|
||||||
|
# no autov3 (e.g. lazy-hash completion) must never clear its existing
|
||||||
|
# mapping. First-time registrations stay O(1).
|
||||||
|
if autov3:
|
||||||
|
autov3 = autov3.lower()
|
||||||
|
if is_re_registration and (existing_hash != sha256 or autov3):
|
||||||
|
stale_autov3_keys = [
|
||||||
|
key for key, mapped_path in self._autov3_to_path.items()
|
||||||
|
if mapped_path == file_path and key != autov3
|
||||||
|
]
|
||||||
|
for key in stale_autov3_keys:
|
||||||
|
del self._autov3_to_path[key]
|
||||||
|
if autov3:
|
||||||
|
self._autov3_to_path[autov3] = file_path
|
||||||
|
|
||||||
|
def add_autov3(self, autov3: str, file_path: str) -> None:
|
||||||
|
"""Add or update an AutoV3-only index entry (used when only AutoV3 is known)"""
|
||||||
|
if not autov3:
|
||||||
|
return
|
||||||
|
autov3 = autov3.lower()
|
||||||
|
self._autov3_to_path[autov3] = file_path
|
||||||
|
|
||||||
def _get_filename_from_path(self, file_path: str) -> str:
|
def _get_filename_from_path(self, file_path: str) -> str:
|
||||||
"""Extract filename without extension from path"""
|
"""Extract filename without extension from path"""
|
||||||
return os.path.splitext(os.path.basename(file_path))[0]
|
return os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
|
||||||
def remove_by_path(self, file_path: str, hash_val: str = None) -> None:
|
def remove_by_path(self, file_path: str, hash_val: Optional[str] = None) -> None:
|
||||||
"""Remove entry by file path"""
|
"""Remove entry by file path"""
|
||||||
filename = self._get_filename_from_path(file_path)
|
filename = self._get_filename_from_path(file_path)
|
||||||
|
|
||||||
@@ -167,6 +197,11 @@ class ModelHashIndex:
|
|||||||
for k in autov2_keys_to_remove:
|
for k in autov2_keys_to_remove:
|
||||||
del self._autov2_to_path[k]
|
del self._autov2_to_path[k]
|
||||||
|
|
||||||
|
# Remove from AutoV3 index
|
||||||
|
autov3_keys_to_remove = [k for k, v in self._autov3_to_path.items() if v == file_path]
|
||||||
|
for k in autov3_keys_to_remove:
|
||||||
|
del self._autov3_to_path[k]
|
||||||
|
|
||||||
def remove_by_hash(self, sha256: str) -> None:
|
def remove_by_hash(self, sha256: str) -> None:
|
||||||
"""Remove entry by hash"""
|
"""Remove entry by hash"""
|
||||||
sha256 = sha256.lower()
|
sha256 = sha256.lower()
|
||||||
@@ -189,6 +224,11 @@ class ModelHashIndex:
|
|||||||
autov2_key = sha256[:10]
|
autov2_key = sha256[:10]
|
||||||
if autov2_key in self._autov2_to_path:
|
if autov2_key in self._autov2_to_path:
|
||||||
del self._autov2_to_path[autov2_key]
|
del self._autov2_to_path[autov2_key]
|
||||||
|
|
||||||
|
# Remove AutoV3 entries pointing to any removed path
|
||||||
|
autov3_keys_to_remove = [k for k, v in self._autov3_to_path.items() if v in paths_to_remove]
|
||||||
|
for k in autov3_keys_to_remove:
|
||||||
|
del self._autov3_to_path[k]
|
||||||
|
|
||||||
# Update filename-to-hash and duplicate filenames for all paths
|
# Update filename-to-hash and duplicate filenames for all paths
|
||||||
for path_to_remove in paths_to_remove:
|
for path_to_remove in paths_to_remove:
|
||||||
@@ -209,22 +249,26 @@ class ModelHashIndex:
|
|||||||
del self._duplicate_filenames[fname]
|
del self._duplicate_filenames[fname]
|
||||||
|
|
||||||
def has_hash(self, hash_value: str) -> bool:
|
def has_hash(self, hash_value: str) -> bool:
|
||||||
"""Check if hash exists in index (SHA256 or AutoV2)"""
|
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
|
||||||
normalized = hash_value.lower()
|
normalized = hash_value.lower()
|
||||||
if normalized in self._hash_to_path:
|
if normalized in self._hash_to_path:
|
||||||
return True
|
return True
|
||||||
if len(normalized) == 10:
|
if len(normalized) == 10:
|
||||||
return normalized in self._autov2_to_path
|
return normalized in self._autov2_to_path
|
||||||
|
if len(normalized) == 12:
|
||||||
|
return normalized in self._autov3_to_path
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_path(self, hash_value: str) -> Optional[str]:
|
def get_path(self, hash_value: str) -> Optional[str]:
|
||||||
"""Get file path for a hash (SHA256 or AutoV2)"""
|
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
|
||||||
normalized = hash_value.lower()
|
normalized = hash_value.lower()
|
||||||
path = self._hash_to_path.get(normalized)
|
path = self._hash_to_path.get(normalized)
|
||||||
if path is not None:
|
if path is not None:
|
||||||
return path
|
return path
|
||||||
if len(normalized) == 10:
|
if len(normalized) == 10:
|
||||||
return self._autov2_to_path.get(normalized)
|
return self._autov2_to_path.get(normalized)
|
||||||
|
if len(normalized) == 12:
|
||||||
|
return self._autov3_to_path.get(normalized)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_hash(self, file_path: str) -> Optional[str]:
|
def get_hash(self, file_path: str) -> Optional[str]:
|
||||||
@@ -243,6 +287,7 @@ class ModelHashIndex:
|
|||||||
self._hash_to_path.clear()
|
self._hash_to_path.clear()
|
||||||
self._filename_to_hash.clear()
|
self._filename_to_hash.clear()
|
||||||
self._autov2_to_path.clear()
|
self._autov2_to_path.clear()
|
||||||
|
self._autov3_to_path.clear()
|
||||||
self._duplicate_hashes.clear()
|
self._duplicate_hashes.clear()
|
||||||
self._duplicate_filenames.clear()
|
self._duplicate_filenames.clear()
|
||||||
|
|
||||||
@@ -253,6 +298,10 @@ class ModelHashIndex:
|
|||||||
def get_all_filenames(self) -> Set[str]:
|
def get_all_filenames(self) -> Set[str]:
|
||||||
"""Get all filenames in the index"""
|
"""Get all filenames in the index"""
|
||||||
return set(self._filename_to_hash.keys())
|
return set(self._filename_to_hash.keys())
|
||||||
|
|
||||||
|
def get_all_autov3(self) -> Dict[str, str]:
|
||||||
|
"""Get a snapshot of all AutoV3 hashes mapped to their file paths"""
|
||||||
|
return dict(self._autov3_to_path)
|
||||||
|
|
||||||
def get_duplicate_hashes(self) -> Dict[str, List[str]]:
|
def get_duplicate_hashes(self) -> Dict[str, List[str]]:
|
||||||
"""Get dictionary of duplicate hashes and their paths"""
|
"""Get dictionary of duplicate hashes and their paths"""
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||||
|
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..services.pending_delete_service import get_pending_delete_service
|
||||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
|
|
||||||
@@ -87,8 +88,8 @@ class ModelLifecycleService:
|
|||||||
scanner,
|
scanner,
|
||||||
metadata_manager,
|
metadata_manager,
|
||||||
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
metadata_loader: Callable[[str], Awaitable[Dict[str, object]]],
|
||||||
recipe_scanner_factory: Callable[[], Awaitable] | None = None,
|
recipe_scanner_factory: Callable[[], Awaitable[Any]] | None = None,
|
||||||
update_service: "ModelUpdateService" | None = None,
|
update_service: Optional["ModelUpdateService"] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._scanner = scanner
|
self._scanner = scanner
|
||||||
self._metadata_manager = metadata_manager
|
self._metadata_manager = metadata_manager
|
||||||
@@ -129,15 +130,33 @@ class ModelLifecycleService:
|
|||||||
target_dir = os.path.dirname(file_path)
|
target_dir = os.path.dirname(file_path)
|
||||||
base_name = os.path.basename(file_path)
|
base_name = os.path.basename(file_path)
|
||||||
file_name, main_extension = os.path.splitext(base_name)
|
file_name, main_extension = os.path.splitext(base_name)
|
||||||
deleted_files = await delete_model_artifacts(
|
|
||||||
target_dir, file_name, main_extension=main_extension
|
# Stage the delete into the pending-delete service when undo is
|
||||||
|
# enabled; a successful stage renames the artifacts away, otherwise
|
||||||
|
# fall back to the direct hard delete.
|
||||||
|
pending_delete_service = await get_pending_delete_service()
|
||||||
|
batch_id = await pending_delete_service.stage_model_delete(
|
||||||
|
scanner=self._scanner,
|
||||||
|
target_dir=target_dir,
|
||||||
|
file_name=file_name,
|
||||||
|
main_extension=main_extension,
|
||||||
|
original_file_path=file_path,
|
||||||
|
cached_entry=cached_entry,
|
||||||
)
|
)
|
||||||
|
deleted_files: List[str] = []
|
||||||
|
if batch_id is None:
|
||||||
|
deleted_files = await delete_model_artifacts(
|
||||||
|
target_dir, file_name, main_extension=main_extension
|
||||||
|
)
|
||||||
|
|
||||||
if cache:
|
if cache:
|
||||||
cache.raw_data = [
|
cache.raw_data = [
|
||||||
item for item in cache.raw_data if item.get("file_path") != file_path
|
item for item in cache.raw_data if item.get("file_path") != file_path
|
||||||
]
|
]
|
||||||
await cache.resort()
|
await cache.resort()
|
||||||
|
bump_cache_version = getattr(self._scanner, "bump_cache_version", None)
|
||||||
|
if callable(bump_cache_version):
|
||||||
|
bump_cache_version()
|
||||||
|
|
||||||
if hasattr(self._scanner, "_hash_index") and self._scanner._hash_index:
|
if hasattr(self._scanner, "_hash_index") and self._scanner._hash_index:
|
||||||
self._scanner._hash_index.remove_by_path(file_path)
|
self._scanner._hash_index.remove_by_path(file_path)
|
||||||
@@ -146,9 +165,13 @@ class ModelLifecycleService:
|
|||||||
|
|
||||||
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||||
if callable(persist_current_cache):
|
if callable(persist_current_cache):
|
||||||
await persist_current_cache()
|
await cast(Awaitable[Any], persist_current_cache())
|
||||||
|
|
||||||
return {"success": True, "deleted_files": deleted_files}
|
return {
|
||||||
|
"success": True,
|
||||||
|
"deleted_files": deleted_files,
|
||||||
|
"batch_id": batch_id,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_model_id_from_payload(payload: Any) -> Optional[int]:
|
def _extract_model_id_from_payload(payload: Any) -> Optional[int]:
|
||||||
@@ -244,6 +267,9 @@ class ModelLifecycleService:
|
|||||||
item for item in cache.raw_data if item["file_path"] != file_path
|
item for item in cache.raw_data if item["file_path"] != file_path
|
||||||
]
|
]
|
||||||
await cache.resort()
|
await cache.resort()
|
||||||
|
bump_cache_version = getattr(self._scanner, "bump_cache_version", None)
|
||||||
|
if callable(bump_cache_version):
|
||||||
|
bump_cache_version()
|
||||||
|
|
||||||
excluded = getattr(self._scanner, "_excluded_models", None)
|
excluded = getattr(self._scanner, "_excluded_models", None)
|
||||||
if isinstance(excluded, list):
|
if isinstance(excluded, list):
|
||||||
@@ -252,7 +278,7 @@ class ModelLifecycleService:
|
|||||||
|
|
||||||
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
|
||||||
if callable(persist_current_cache):
|
if callable(persist_current_cache):
|
||||||
await persist_current_cache()
|
await cast(Awaitable[Any], persist_current_cache())
|
||||||
|
|
||||||
message = f"Model {os.path.basename(file_path)} excluded"
|
message = f"Model {os.path.basename(file_path)} excluded"
|
||||||
return {"success": True, "message": message}
|
return {"success": True, "message": message}
|
||||||
@@ -357,7 +383,8 @@ class ModelLifecycleService:
|
|||||||
|
|
||||||
if os.path.exists(metadata_path):
|
if os.path.exists(metadata_path):
|
||||||
metadata = await self._metadata_loader(metadata_path)
|
metadata = await self._metadata_loader(metadata_path)
|
||||||
hash_value = metadata.get("sha256") if isinstance(metadata, dict) else None
|
raw_hash = metadata.get("sha256") if isinstance(metadata, dict) else None
|
||||||
|
hash_value = raw_hash if isinstance(raw_hash, str) else None
|
||||||
|
|
||||||
renamed_files: List[str] = []
|
renamed_files: List[str] = []
|
||||||
new_metadata_path: Optional[str] = None
|
new_metadata_path: Optional[str] = None
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from .errors import RateLimitError, ResourceNotFoundError
|
|||||||
try:
|
try:
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
BeautifulSoup = None # type: ignore[assignment]
|
BeautifulSoup = None # pyright: ignore[reportAssignmentType]
|
||||||
_BS4_IMPORT_ERROR = exc
|
_BS4_IMPORT_ERROR = exc
|
||||||
else:
|
else:
|
||||||
_BS4_IMPORT_ERROR = None
|
_BS4_IMPORT_ERROR = None
|
||||||
@@ -18,7 +18,7 @@ else:
|
|||||||
try:
|
try:
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
aiosqlite = None # type: ignore[assignment]
|
aiosqlite = None # pyright: ignore[reportAssignmentType]
|
||||||
_AIOSQLITE_IMPORT_ERROR = exc
|
_AIOSQLITE_IMPORT_ERROR = exc
|
||||||
else:
|
else:
|
||||||
_AIOSQLITE_IMPORT_ERROR = None
|
_AIOSQLITE_IMPORT_ERROR = None
|
||||||
@@ -105,24 +105,24 @@ class ModelMetadataProvider(ABC):
|
|||||||
"""Base abstract class for all model metadata providers"""
|
"""Base abstract class for all model metadata providers"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Find model by hash value"""
|
"""Find model by hash value"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get all versions of a model with their details"""
|
"""Get all versions of a model with their details"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def get_model_versions_bulk(
|
async def get_model_versions_bulk(
|
||||||
self, model_ids: Sequence[int]
|
self, model_ids: Sequence[int]
|
||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict[str, Any]]]:
|
||||||
"""Fetch model versions for multiple model ids when supported."""
|
"""Fetch model versions for multiple model ids when supported."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def get_model_versions_by_hashes(
|
async def get_model_versions_by_hashes(
|
||||||
self, hashes: List[str]
|
self, hashes: List[str]
|
||||||
) -> Optional[List[Dict]]:
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""Fetch full version details for multiple SHA256 hashes.
|
"""Fetch full version details for multiple SHA256 hashes.
|
||||||
|
|
||||||
Used specifically to retrieve ``usageControl`` which is only
|
Used specifically to retrieve ``usageControl`` which is only
|
||||||
@@ -133,17 +133,17 @@ class ModelMetadataProvider(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get specific model version with additional metadata"""
|
"""Get specific model version with additional metadata"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Fetch model version metadata"""
|
"""Fetch model version metadata"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch one page of models owned by the specified user.
|
"""Fetch one page of models owned by the specified user.
|
||||||
|
|
||||||
Returns ``{"items": [...], "nextCursor": <str|None>}`` on success,
|
Returns ``{"items": [...], "nextCursor": <str|None>}`` on success,
|
||||||
@@ -161,29 +161,29 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
|||||||
def __init__(self, civitai_client):
|
def __init__(self, civitai_client):
|
||||||
self.client = civitai_client
|
self.client = civitai_client
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
return await self.client.get_model_by_hash(model_hash)
|
return await self.client.get_model_by_hash(model_hash)
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return await self.client.get_model_versions(model_id)
|
return await self.client.get_model_versions(model_id)
|
||||||
|
|
||||||
async def get_model_versions_bulk(
|
async def get_model_versions_bulk(
|
||||||
self, model_ids: Sequence[int]
|
self, model_ids: Sequence[int]
|
||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict[str, Any]]]:
|
||||||
return await self.client.get_model_versions_bulk(model_ids)
|
return await self.client.get_model_versions_bulk(model_ids)
|
||||||
|
|
||||||
async def get_model_versions_by_hashes(
|
async def get_model_versions_by_hashes(
|
||||||
self, hashes: List[str]
|
self, hashes: List[str]
|
||||||
) -> Optional[List[Dict]]:
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
return await self.client.get_model_versions_by_hashes(hashes)
|
return await self.client.get_model_versions_by_hashes(hashes)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
return await self.client.get_model_version(model_id, version_id)
|
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]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
return await self.client.get_model_version_info(version_id)
|
return await self.client.get_model_version_info(version_id)
|
||||||
|
|
||||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
return await self.client.get_user_models(username, cursor)
|
return await self.client.get_user_models(username, cursor)
|
||||||
|
|
||||||
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
@@ -195,19 +195,19 @@ class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
|||||||
def __init__(self, civarchive_client):
|
def __init__(self, civarchive_client):
|
||||||
self.client = civarchive_client
|
self.client = civarchive_client
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
return await self.client.get_model_by_hash(model_hash)
|
return await self.client.get_model_by_hash(model_hash)
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return await self.client.get_model_versions(model_id)
|
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]:
|
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
return await self.client.get_model_version(model_id, version_id)
|
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]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
return await self.client.get_model_version_info(version_id)
|
return await self.client.get_model_version_info(version_id)
|
||||||
|
|
||||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Not supported by CivArchive provider"""
|
"""Not supported by CivArchive provider"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
|||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
self._aiosqlite = _require_aiosqlite()
|
self._aiosqlite = _require_aiosqlite()
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Find model by hash value from SQLite database"""
|
"""Find model by hash value from SQLite database"""
|
||||||
async with self._aiosqlite.connect(self.db_path) as db:
|
async with self._aiosqlite.connect(self.db_path) as db:
|
||||||
# Look up in model_files table to get model_id and version_id
|
# Look up in model_files table to get model_id and version_id
|
||||||
@@ -243,7 +243,7 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
|||||||
result = await self._get_version_with_model_data(db, model_id, version_id)
|
result = await self._get_version_with_model_data(db, model_id, version_id)
|
||||||
return result, None if result else "Error retrieving model data"
|
return result, None if result else "Error retrieving model data"
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get all versions of a model from SQLite database"""
|
"""Get all versions of a model from SQLite database"""
|
||||||
async with self._aiosqlite.connect(self.db_path) as db:
|
async with self._aiosqlite.connect(self.db_path) as db:
|
||||||
db.row_factory = self._aiosqlite.Row
|
db.row_factory = self._aiosqlite.Row
|
||||||
@@ -299,7 +299,7 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
|||||||
'name': model_name
|
'name': model_name
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get specific model version with additional metadata from SQLite database"""
|
"""Get specific model version with additional metadata from SQLite database"""
|
||||||
if not model_id and not version_id:
|
if not model_id and not version_id:
|
||||||
return None
|
return None
|
||||||
@@ -339,7 +339,7 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
|||||||
# Now we have both model_id and version_id, get the full data
|
# Now we have both model_id and version_id, get the full data
|
||||||
return await self._get_version_with_model_data(db, model_id, version_id)
|
return await self._get_version_with_model_data(db, model_id, version_id)
|
||||||
|
|
||||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Fetch model version metadata from SQLite database"""
|
"""Fetch model version metadata from SQLite database"""
|
||||||
async with self._aiosqlite.connect(self.db_path) as db:
|
async with self._aiosqlite.connect(self.db_path) as db:
|
||||||
db.row_factory = self._aiosqlite.Row
|
db.row_factory = self._aiosqlite.Row
|
||||||
@@ -358,11 +358,11 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
|||||||
version_data = await self._get_version_with_model_data(db, model_id, version_id)
|
version_data = await self._get_version_with_model_data(db, model_id, version_id)
|
||||||
return version_data, None
|
return version_data, None
|
||||||
|
|
||||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Listing models by username is not supported for archive database"""
|
"""Listing models by username is not supported for archive database"""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _get_version_with_model_data(self, db, model_id, version_id) -> Optional[Dict]:
|
async def _get_version_with_model_data(self, db, model_id, version_id) -> Optional[Dict[str, Any]]:
|
||||||
"""Helper to build version data with model information"""
|
"""Helper to build version data with model information"""
|
||||||
# Get version details
|
# Get version details
|
||||||
version_query = "SELECT name, base_model, data FROM model_versions WHERE id = ? AND model_id = ?"
|
version_query = "SELECT name, base_model, data FROM model_versions WHERE id = ? AND model_id = ?"
|
||||||
@@ -485,7 +485,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
jitter_ratio=self._rate_limit_jitter_ratio,
|
jitter_ratio=self._rate_limit_jitter_ratio,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result, error = await self._call_with_rate_limit(
|
result, error = await self._call_with_rate_limit(
|
||||||
@@ -507,7 +507,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None, "Model not found"
|
return None, "Model not found"
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
not_found_confirmed = False
|
not_found_confirmed = False
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
@@ -538,7 +538,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result = await self._call_with_rate_limit(
|
result = await self._call_with_rate_limit(
|
||||||
@@ -561,7 +561,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result, error = await self._call_with_rate_limit(
|
result, error = await self._call_with_rate_limit(
|
||||||
@@ -585,7 +585,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
|
|
||||||
async def get_model_versions_by_hashes(
|
async def get_model_versions_by_hashes(
|
||||||
self, hashes: List[str]
|
self, hashes: List[str]
|
||||||
) -> Optional[List[Dict]]:
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result = await self._call_with_rate_limit(
|
result = await self._call_with_rate_limit(
|
||||||
@@ -613,7 +613,7 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
for provider, label in self._iter_providers():
|
for provider, label in self._iter_providers():
|
||||||
try:
|
try:
|
||||||
result = await self._call_with_rate_limit(
|
result = await self._call_with_rate_limit(
|
||||||
@@ -681,14 +681,14 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
def __getattr__(self, item):
|
def __getattr__(self, item):
|
||||||
return getattr(self._provider, item)
|
return getattr(self._provider, item)
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_model_by_hash,
|
self._provider.get_model_by_hash,
|
||||||
model_hash,
|
model_hash,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_model_versions,
|
self._provider.get_model_versions,
|
||||||
@@ -698,7 +698,7 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
async def get_model_versions_bulk(
|
async def get_model_versions_bulk(
|
||||||
self,
|
self,
|
||||||
model_ids: Sequence[int],
|
model_ids: Sequence[int],
|
||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict[str, Any]]]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_model_versions_bulk,
|
self._provider.get_model_versions_bulk,
|
||||||
@@ -707,14 +707,14 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
|
|
||||||
async def get_model_versions_by_hashes(
|
async def get_model_versions_by_hashes(
|
||||||
self, hashes: List[str]
|
self, hashes: List[str]
|
||||||
) -> Optional[List[Dict]]:
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_model_versions_by_hashes,
|
self._provider.get_model_versions_by_hashes,
|
||||||
hashes,
|
hashes,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_model_version,
|
self._provider.get_model_version,
|
||||||
@@ -722,14 +722,14 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
|||||||
version_id,
|
version_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_model_version_info,
|
self._provider.get_model_version_info,
|
||||||
version_id,
|
version_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict]:
|
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
return await self._rate_limit_helper.run(
|
return await self._rate_limit_helper.run(
|
||||||
self._label,
|
self._label,
|
||||||
self._provider.get_user_models,
|
self._provider.get_user_models,
|
||||||
@@ -762,12 +762,12 @@ class ModelMetadataProviderManager:
|
|||||||
if is_default or self.default_provider is None:
|
if is_default or self.default_provider is None:
|
||||||
self.default_provider = name
|
self.default_provider = name
|
||||||
|
|
||||||
async def get_model_by_hash(self, model_hash: str, provider_name: str = None) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_by_hash(self, model_hash: str, provider_name: Optional[str] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Find model by hash using specified or default provider"""
|
"""Find model by hash using specified or default provider"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_model_by_hash(model_hash)
|
return await provider.get_model_by_hash(model_hash)
|
||||||
|
|
||||||
async def get_model_versions(self, model_id: str, provider_name: str = None) -> Optional[Dict]:
|
async def get_model_versions(self, model_id: str, provider_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get model versions using specified or default provider"""
|
"""Get model versions using specified or default provider"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_model_versions(model_id)
|
return await provider.get_model_versions(model_id)
|
||||||
@@ -775,8 +775,8 @@ class ModelMetadataProviderManager:
|
|||||||
async def get_model_versions_bulk(
|
async def get_model_versions_bulk(
|
||||||
self,
|
self,
|
||||||
model_ids: Sequence[int],
|
model_ids: Sequence[int],
|
||||||
provider_name: str = None,
|
provider_name: Optional[str] = None,
|
||||||
) -> Optional[Dict[int, Dict]]:
|
) -> Optional[Dict[int, Dict[str, Any]]]:
|
||||||
"""Fetch model versions for multiple model ids when supported by provider."""
|
"""Fetch model versions for multiple model ids when supported by provider."""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
try:
|
try:
|
||||||
@@ -784,12 +784,12 @@ class ModelMetadataProviderManager:
|
|||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_model_version(self, model_id: int = None, version_id: int = None, provider_name: str = None) -> Optional[Dict]:
|
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None, provider_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get specific model version using specified or default provider"""
|
"""Get specific model version using specified or default provider"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_model_version(model_id, version_id)
|
return await provider.get_model_version(model_id, version_id)
|
||||||
|
|
||||||
async def get_model_version_info(self, version_id: str, provider_name: str = None) -> Tuple[Optional[Dict], Optional[str]]:
|
async def get_model_version_info(self, version_id: str, provider_name: Optional[str] = None) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||||
"""Fetch model version info using specified or default provider"""
|
"""Fetch model version info using specified or default provider"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_model_version_info(version_id)
|
return await provider.get_model_version_info(version_id)
|
||||||
@@ -797,8 +797,8 @@ class ModelMetadataProviderManager:
|
|||||||
async def get_model_versions_by_hashes(
|
async def get_model_versions_by_hashes(
|
||||||
self,
|
self,
|
||||||
hashes: List[str],
|
hashes: List[str],
|
||||||
provider_name: str = None,
|
provider_name: Optional[str] = None,
|
||||||
) -> Optional[List[Dict]]:
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
try:
|
try:
|
||||||
return await provider.get_model_versions_by_hashes(hashes)
|
return await provider.get_model_versions_by_hashes(hashes)
|
||||||
@@ -808,19 +808,19 @@ class ModelMetadataProviderManager:
|
|||||||
async def get_user_models(
|
async def get_user_models(
|
||||||
self,
|
self,
|
||||||
username: str,
|
username: str,
|
||||||
provider_name: str = None,
|
provider_name: Optional[str] = None,
|
||||||
cursor: Optional[str] = None,
|
cursor: Optional[str] = None,
|
||||||
) -> Optional[Dict]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch one page of models owned by the specified user"""
|
"""Fetch one page of models owned by the specified user"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_user_models(username, cursor)
|
return await provider.get_user_models(username, cursor)
|
||||||
|
|
||||||
async def get_creator_model_count(self, username: str, provider_name: str = None) -> Optional[int]:
|
async def get_creator_model_count(self, username: str, provider_name: Optional[str] = None) -> Optional[int]:
|
||||||
"""Best-effort published model count for the specified user"""
|
"""Best-effort published model count for the specified user"""
|
||||||
provider = self._get_provider(provider_name)
|
provider = self._get_provider(provider_name)
|
||||||
return await provider.get_creator_model_count(username)
|
return await provider.get_creator_model_count(username)
|
||||||
|
|
||||||
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
|
def _get_provider(self, provider_name: Optional[str] = None) -> ModelMetadataProvider:
|
||||||
"""Get provider by name or default provider"""
|
"""Get provider by name or default provider"""
|
||||||
if provider_name:
|
if provider_name:
|
||||||
if provider_name not in self.providers:
|
if provider_name not in self.providers:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import (
|
|||||||
Tuple,
|
Tuple,
|
||||||
Protocol,
|
Protocol,
|
||||||
Callable,
|
Callable,
|
||||||
|
cast,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..utils.constants import NSFW_LEVELS
|
from ..utils.constants import NSFW_LEVELS
|
||||||
@@ -309,7 +310,7 @@ class ModelFilterSet:
|
|||||||
else:
|
else:
|
||||||
include_tags.add(normalized)
|
include_tags.add(normalized)
|
||||||
else:
|
else:
|
||||||
include_tags = {tag.strip().lower() for tag in tag_filters if tag}
|
include_tags = {tag.strip().lower() for tag in cast(Iterable[Any], tag_filters) if tag}
|
||||||
|
|
||||||
if include_tags:
|
if include_tags:
|
||||||
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
|
||||||
|
|||||||
+322
-40
@@ -5,11 +5,11 @@ import asyncio
|
|||||||
import time
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Set, Type, Union
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Type, Union, cast
|
||||||
|
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256
|
from ..utils.file_utils import find_preview_file, get_preview_extension, calculate_sha256, calculate_autov3
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.civitai_utils import resolve_license_info
|
from ..utils.civitai_utils import resolve_license_info
|
||||||
from .model_cache import ModelCache
|
from .model_cache import ModelCache
|
||||||
@@ -19,17 +19,33 @@ from .service_registry import ServiceRegistry
|
|||||||
from .websocket_manager import ws_manager
|
from .websocket_manager import ws_manager
|
||||||
from .persistent_model_cache import get_persistent_cache
|
from .persistent_model_cache import get_persistent_cache
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
from .pending_delete_service import PENDING_DELETE_DIR_NAME, get_pending_delete_service
|
||||||
from .cache_entry_validator import CacheEntryValidator
|
from .cache_entry_validator import CacheEntryValidator
|
||||||
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_excluded_dir(name: str) -> bool:
|
||||||
|
"""Return True when a directory entry must be skipped during model walks.
|
||||||
|
|
||||||
|
The pending-delete staging directory is excluded so staged files never
|
||||||
|
appear in the library as ghost model entries.
|
||||||
|
"""
|
||||||
|
return name == PENDING_DELETE_DIR_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pending_delete_path(path: str) -> bool:
|
||||||
|
"""Return True when any path component is the pending-delete staging dir."""
|
||||||
|
normalized = str(path).replace(os.sep, "/")
|
||||||
|
return any(part == PENDING_DELETE_DIR_NAME for part in normalized.split("/"))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CacheBuildResult:
|
class CacheBuildResult:
|
||||||
"""Represents the outcome of scanning model files for cache building."""
|
"""Represents the outcome of scanning model files for cache building."""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict[str, Any]]
|
||||||
hash_index: ModelHashIndex
|
hash_index: ModelHashIndex
|
||||||
tags_count: Dict[str, int]
|
tags_count: Dict[str, int]
|
||||||
excluded_models: List[str]
|
excluded_models: List[str]
|
||||||
@@ -59,7 +75,7 @@ class ModelScanner:
|
|||||||
lock = cls._get_lock()
|
lock = cls._get_lock()
|
||||||
async with lock:
|
async with lock:
|
||||||
if cls not in cls._instances:
|
if cls not in cls._instances:
|
||||||
cls._instances[cls] = cls()
|
cls._instances[cls] = cls() # pyright: ignore[reportCallIssue]
|
||||||
return cls._instances[cls]
|
return cls._instances[cls]
|
||||||
|
|
||||||
def __init__(self, model_type: str, model_class: Type[BaseModelMetadata], file_extensions: Set[str], hash_index: Optional[ModelHashIndex] = None):
|
def __init__(self, model_type: str, model_class: Type[BaseModelMetadata], file_extensions: Set[str], hash_index: Optional[ModelHashIndex] = None):
|
||||||
@@ -78,7 +94,8 @@ class ModelScanner:
|
|||||||
self.model_type = model_type
|
self.model_type = model_type
|
||||||
self.model_class = model_class
|
self.model_class = model_class
|
||||||
self.file_extensions = file_extensions
|
self.file_extensions = file_extensions
|
||||||
self._cache = None
|
self._cache: Any = None
|
||||||
|
self._cache_version: int = 0
|
||||||
self._hash_index = hash_index or ModelHashIndex()
|
self._hash_index = hash_index or ModelHashIndex()
|
||||||
self._tags_count = {} # Dictionary to store tag counts
|
self._tags_count = {} # Dictionary to store tag counts
|
||||||
self._is_initializing = False # Flag to track initialization state
|
self._is_initializing = False # Flag to track initialization state
|
||||||
@@ -86,6 +103,7 @@ class ModelScanner:
|
|||||||
self._persistent_cache = get_persistent_cache()
|
self._persistent_cache = get_persistent_cache()
|
||||||
self._name_display_mode = self._resolve_name_display_mode()
|
self._name_display_mode = self._resolve_name_display_mode()
|
||||||
self._cancel_requested = False # Flag for cancellation
|
self._cancel_requested = False # Flag for cancellation
|
||||||
|
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
@@ -97,6 +115,25 @@ class ModelScanner:
|
|||||||
# Register this service
|
# Register this service
|
||||||
asyncio.create_task(self._register_service())
|
asyncio.create_task(self._register_service())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_version(self) -> int:
|
||||||
|
"""Monotonic version counter for the in-memory cache.
|
||||||
|
|
||||||
|
Every write path that mutates scanner cache state calls
|
||||||
|
:meth:`bump_cache_version`, so consumers (e.g. RecipeScanner) can
|
||||||
|
detect when a cached derivation of the raw data is stale. Reads never
|
||||||
|
bump.
|
||||||
|
"""
|
||||||
|
return self._cache_version
|
||||||
|
|
||||||
|
def bump_cache_version(self) -> None:
|
||||||
|
"""Invalidate derived caches by incrementing the cache version.
|
||||||
|
|
||||||
|
Public because external services (model lifecycle, route handlers)
|
||||||
|
rewrite scanner raw_data directly and must be able to invalidate it.
|
||||||
|
"""
|
||||||
|
self._cache_version += 1
|
||||||
|
|
||||||
def on_library_changed(self) -> None:
|
def on_library_changed(self) -> None:
|
||||||
"""Reset caches when the active library changes."""
|
"""Reset caches when the active library changes."""
|
||||||
self._persistent_cache = get_persistent_cache()
|
self._persistent_cache = get_persistent_cache()
|
||||||
@@ -106,6 +143,7 @@ class ModelScanner:
|
|||||||
self._excluded_models = []
|
self._excluded_models = []
|
||||||
self._is_initializing = False
|
self._is_initializing = False
|
||||||
self._name_display_mode = self._resolve_name_display_mode()
|
self._name_display_mode = self._resolve_name_display_mode()
|
||||||
|
self.bump_cache_version()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -182,7 +220,7 @@ class ModelScanner:
|
|||||||
is_mapping = isinstance(source, Mapping)
|
is_mapping = isinstance(source, Mapping)
|
||||||
|
|
||||||
def get_value(key: str, default: Any = None) -> Any:
|
def get_value(key: str, default: Any = None) -> Any:
|
||||||
if is_mapping:
|
if isinstance(source, Mapping):
|
||||||
return source.get(key, default)
|
return source.get(key, default)
|
||||||
|
|
||||||
sentinel = object()
|
sentinel = object()
|
||||||
@@ -225,6 +263,19 @@ class ModelScanner:
|
|||||||
if not isinstance(notes, str):
|
if not isinstance(notes, str):
|
||||||
notes = str(notes)
|
notes = str(notes)
|
||||||
|
|
||||||
|
# AutoV3 three-state contract: absent key / None = "not checked yet",
|
||||||
|
# "" = "checked but unavailable" (never re-read the header), else the
|
||||||
|
# 12-char lowercase hex value. A metadata object already follows the
|
||||||
|
# contract and is passed through unchanged; a payload dict only carries
|
||||||
|
# an explicit checked state when the key is present.
|
||||||
|
if is_mapping:
|
||||||
|
if 'autov3' in source:
|
||||||
|
entry_autov3 = source['autov3'] or ''
|
||||||
|
else:
|
||||||
|
entry_autov3 = None
|
||||||
|
else:
|
||||||
|
entry_autov3 = get_value('autov3', None)
|
||||||
|
|
||||||
entry: Dict[str, Any] = {
|
entry: Dict[str, Any] = {
|
||||||
'file_path': normalized_path,
|
'file_path': normalized_path,
|
||||||
# file_name is always stored WITHOUT extension (e.g. "OWSMianne_ANIMA_V1",
|
# file_name is always stored WITHOUT extension (e.g. "OWSMianne_ANIMA_V1",
|
||||||
@@ -238,6 +289,7 @@ class ModelScanner:
|
|||||||
'size': int(get_value('size', 0) or 0),
|
'size': int(get_value('size', 0) or 0),
|
||||||
'modified': float(get_value('modified', 0.0) or 0.0),
|
'modified': float(get_value('modified', 0.0) or 0.0),
|
||||||
'sha256': (get_value('sha256', '') or '').lower(),
|
'sha256': (get_value('sha256', '') or '').lower(),
|
||||||
|
'autov3': entry_autov3,
|
||||||
'base_model': get_value('base_model', '') or '',
|
'base_model': get_value('base_model', '') or '',
|
||||||
'preview_url': preview_url,
|
'preview_url': preview_url,
|
||||||
'preview_nsfw_level': int(get_value('preview_nsfw_level', 0) or 0),
|
'preview_nsfw_level': int(get_value('preview_nsfw_level', 0) or 0),
|
||||||
@@ -473,6 +525,13 @@ class ModelScanner:
|
|||||||
if sha_value and path:
|
if sha_value and path:
|
||||||
hash_index.add_entry(sha_value.lower(), path)
|
hash_index.add_entry(sha_value.lower(), path)
|
||||||
|
|
||||||
|
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||||
|
# cover every known autov3 -> path mapping regardless of whether a
|
||||||
|
# sha256 row also exists for the same file.
|
||||||
|
for autov3_value, path in persisted.autov3_hash_rows:
|
||||||
|
if autov3_value and path:
|
||||||
|
hash_index.add_autov3(autov3_value.lower(), path)
|
||||||
|
|
||||||
tags_count: Dict[str, int] = {}
|
tags_count: Dict[str, int] = {}
|
||||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||||
for item in persisted.raw_data:
|
for item in persisted.raw_data:
|
||||||
@@ -541,8 +600,30 @@ class ModelScanner:
|
|||||||
'scanner_type': self.model_type,
|
'scanner_type': self.model_type,
|
||||||
'pageType': page_type
|
'pageType': page_type
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Schedule the one-time AutoV3 backfill task (at most once per process)
|
||||||
|
# so entries loaded from a persisted snapshot that predates autov3 get
|
||||||
|
# their checked state computed in the background. The task never blocks
|
||||||
|
# or crashes the load path.
|
||||||
|
if not self._autov3_backfill_scheduled:
|
||||||
|
self._autov3_backfill_scheduled = True
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
loop = None
|
||||||
|
if loop is not None:
|
||||||
|
loop.create_task(self._run_autov3_backfill())
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _run_autov3_backfill(self) -> None:
|
||||||
|
"""Backfill autov3 for entries loaded from the persisted cache that lack it."""
|
||||||
|
try:
|
||||||
|
from ..services.autov3_backfill_service import Autov3BackfillService # lazy import (module created by another unit)
|
||||||
|
await Autov3BackfillService.get_instance().backfill(self)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("AutoV3 backfill failed: %s", exc)
|
||||||
|
|
||||||
async def _save_persistent_cache(self, scan_result: CacheBuildResult) -> None:
|
async def _save_persistent_cache(self, scan_result: CacheBuildResult) -> None:
|
||||||
if not scan_result or not getattr(self, '_persistent_cache', None):
|
if not scan_result or not getattr(self, '_persistent_cache', None):
|
||||||
return
|
return
|
||||||
@@ -555,6 +636,7 @@ class ModelScanner:
|
|||||||
return
|
return
|
||||||
|
|
||||||
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
|
hash_snapshot = self._build_hash_index_snapshot(scan_result.hash_index)
|
||||||
|
autov3_snapshot = self._build_autov3_index_snapshot(scan_result.hash_index)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
@@ -563,7 +645,8 @@ class ModelScanner:
|
|||||||
self.model_type,
|
self.model_type,
|
||||||
list(scan_result.raw_data),
|
list(scan_result.raw_data),
|
||||||
hash_snapshot,
|
hash_snapshot,
|
||||||
list(scan_result.excluded_models)
|
list(scan_result.excluded_models),
|
||||||
|
autov3_snapshot,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
|
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
|
||||||
@@ -589,6 +672,20 @@ class ModelScanner:
|
|||||||
bucket.append(path)
|
bucket.append(path)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
def _build_autov3_index_snapshot(self, hash_index: Optional[ModelHashIndex]) -> Dict[str, List[str]]:
|
||||||
|
"""Build the autov3 -> [paths] snapshot for the persisted cache."""
|
||||||
|
snapshot: Dict[str, List[str]] = {}
|
||||||
|
if not hash_index:
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
for autov3_value, path in hash_index.get_all_autov3().items():
|
||||||
|
if not autov3_value or not path:
|
||||||
|
continue
|
||||||
|
bucket = snapshot.setdefault(autov3_value.lower(), [])
|
||||||
|
if path not in bucket:
|
||||||
|
bucket.append(path)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
async def _persist_current_cache(self) -> None:
|
async def _persist_current_cache(self) -> None:
|
||||||
if self._cache is None or not getattr(self, '_persistent_cache', None):
|
if self._cache is None or not getattr(self, '_persistent_cache', None):
|
||||||
return
|
return
|
||||||
@@ -630,6 +727,8 @@ class ModelScanner:
|
|||||||
if ext in self.file_extensions:
|
if ext in self.file_extensions:
|
||||||
total_files += 1
|
total_files += 1
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
|
if _is_excluded_dir(entry.name):
|
||||||
|
continue
|
||||||
count_recursive(entry.path)
|
count_recursive(entry.path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
||||||
@@ -712,7 +811,7 @@ class ModelScanner:
|
|||||||
else:
|
else:
|
||||||
await self._reconcile_cache()
|
await self._reconcile_cache()
|
||||||
|
|
||||||
return self._cache
|
return cast(ModelCache, self._cache)
|
||||||
|
|
||||||
async def _initialize_cache(self) -> None:
|
async def _initialize_cache(self) -> None:
|
||||||
"""Initialize or refresh the cache"""
|
"""Initialize or refresh the cache"""
|
||||||
@@ -783,7 +882,8 @@ class ModelScanner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Recursively scan directory
|
# Recursively scan directory
|
||||||
for root, _, files in os.walk(root_path, followlinks=True):
|
for root, dirnames, files in os.walk(root_path, followlinks=True):
|
||||||
|
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||||
real_root = os.path.realpath(root)
|
real_root = os.path.realpath(root)
|
||||||
if real_root in visited_real_paths:
|
if real_root in visited_real_paths:
|
||||||
continue
|
continue
|
||||||
@@ -872,6 +972,8 @@ class ModelScanner:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
model_data = validation_result.entry
|
model_data = validation_result.entry
|
||||||
|
if model_data is None:
|
||||||
|
continue
|
||||||
|
|
||||||
self._ensure_license_flags(model_data)
|
self._ensure_license_flags(model_data)
|
||||||
# Add to cache
|
# Add to cache
|
||||||
@@ -880,7 +982,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
# Update hash index if available
|
# Update hash index if available
|
||||||
if 'sha256' in model_data and 'file_path' in model_data:
|
if 'sha256' in model_data and 'file_path' in model_data:
|
||||||
self._hash_index.add_entry(model_data['sha256'].lower(), model_data['file_path'])
|
self._hash_index.add_entry(
|
||||||
|
model_data['sha256'].lower(),
|
||||||
|
model_data['file_path'],
|
||||||
|
model_data.get('autov3') or None
|
||||||
|
)
|
||||||
|
|
||||||
# Update tags count
|
# Update tags count
|
||||||
if 'tags' in model_data and model_data['tags']:
|
if 'tags' in model_data and model_data['tags']:
|
||||||
@@ -928,8 +1034,8 @@ class ModelScanner:
|
|||||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
||||||
|
|
||||||
dedup_removed = 0
|
dedup_removed = 0
|
||||||
seen_paths: set = set()
|
seen_paths: set[str] = set()
|
||||||
deduped: list = []
|
deduped: list[Dict[str, Any]] = []
|
||||||
for item in reversed(self._cache.raw_data):
|
for item in reversed(self._cache.raw_data):
|
||||||
path = item.get('file_path', '')
|
path = item.get('file_path', '')
|
||||||
if path not in seen_paths:
|
if path not in seen_paths:
|
||||||
@@ -964,6 +1070,7 @@ class ModelScanner:
|
|||||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
|
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
self._is_initializing = False # Unset flag
|
self._is_initializing = False # Unset flag
|
||||||
|
self.bump_cache_version()
|
||||||
|
|
||||||
def is_initializing(self) -> bool:
|
def is_initializing(self) -> bool:
|
||||||
"""Check if the scanner is currently initializing"""
|
"""Check if the scanner is currently initializing"""
|
||||||
@@ -1044,11 +1151,16 @@ class ModelScanner:
|
|||||||
*,
|
*,
|
||||||
hash_index: Optional[ModelHashIndex] = None,
|
hash_index: Optional[ModelHashIndex] = None,
|
||||||
excluded_models: Optional[List[str]] = None
|
excluded_models: Optional[List[str]] = None
|
||||||
) -> Dict:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Process a single model file and return its metadata"""
|
"""Process a single model file and return its metadata"""
|
||||||
hash_index = hash_index or self._hash_index
|
hash_index = hash_index or self._hash_index
|
||||||
excluded_models = excluded_models if excluded_models is not None else self._excluded_models
|
excluded_models = excluded_models if excluded_models is not None else self._excluded_models
|
||||||
|
|
||||||
|
# Belt-and-braces: staged files must never become library entries even
|
||||||
|
# if a caller invokes this method directly with a staging path.
|
||||||
|
if _is_pending_delete_path(file_path):
|
||||||
|
return None
|
||||||
|
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
|
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||||
|
|
||||||
if should_skip:
|
if should_skip:
|
||||||
@@ -1068,7 +1180,7 @@ class ModelScanner:
|
|||||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
file_info['name'] = file_name
|
file_info['name'] = file_name
|
||||||
|
|
||||||
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
|
metadata = cast(Any, self.model_class).from_civitai_info(version_info, file_info, file_path)
|
||||||
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
|
||||||
await MetadataManager.save_metadata(file_path, metadata)
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
|
logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
|
||||||
@@ -1105,6 +1217,8 @@ class ModelScanner:
|
|||||||
if metadata is None:
|
if metadata is None:
|
||||||
metadata = await self._create_default_metadata(file_path)
|
metadata = await self._create_default_metadata(file_path)
|
||||||
|
|
||||||
|
assert metadata is not None
|
||||||
|
|
||||||
# Hook: allow subclasses to adjust metadata
|
# Hook: allow subclasses to adjust metadata
|
||||||
metadata = self.adjust_metadata(metadata, file_path, root_path)
|
metadata = self.adjust_metadata(metadata, file_path, root_path)
|
||||||
|
|
||||||
@@ -1130,6 +1244,36 @@ class ModelScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to compute SHA256 for {file_path}: {e}")
|
logger.error(f"Failed to compute SHA256 for {file_path}: {e}")
|
||||||
|
|
||||||
|
# AutoV3 resolution: prefer the Civitai AutoV3 reported for the file
|
||||||
|
# whose SHA256 matches (authoritative for recipe matching), falling
|
||||||
|
# back to the embedded safetensors header hash only for models never
|
||||||
|
# checked before (autov3 is None). A checked-unavailable state ('')
|
||||||
|
# is only upgraded by Civitai data — the header is never re-read.
|
||||||
|
current_autov3 = model_data.get('autov3')
|
||||||
|
if current_autov3 in (None, ''):
|
||||||
|
try:
|
||||||
|
civitai_data = None
|
||||||
|
if isinstance(metadata, BaseModelMetadata):
|
||||||
|
civitai_data = metadata.civitai
|
||||||
|
elif isinstance(metadata, dict):
|
||||||
|
civitai_data = metadata.get("civitai")
|
||||||
|
autov3 = autov3_from_civitai_files(
|
||||||
|
civitai_data, model_data.get("sha256") or ""
|
||||||
|
) or ""
|
||||||
|
if not autov3 and current_autov3 is None:
|
||||||
|
autov3 = (calculate_autov3(os.path.realpath(file_path)) or '').lower()
|
||||||
|
if autov3 != current_autov3:
|
||||||
|
model_data['autov3'] = autov3
|
||||||
|
if isinstance(metadata, BaseModelMetadata):
|
||||||
|
metadata.autov3 = autov3
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
elif isinstance(metadata, dict):
|
||||||
|
# Dict payload: JSON null encodes the checked-unavailable state.
|
||||||
|
metadata['autov3'] = autov3 or None
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to resolve AutoV3 for {file_path}: {e}")
|
||||||
|
|
||||||
# Skip excluded models
|
# Skip excluded models
|
||||||
if model_data.get('exclude', False):
|
if model_data.get('exclude', False):
|
||||||
excluded_models.append(model_data['file_path'])
|
excluded_models.append(model_data['file_path'])
|
||||||
@@ -1169,6 +1313,8 @@ class ModelScanner:
|
|||||||
|
|
||||||
self._log_duplicate_filename_summary()
|
self._log_duplicate_filename_summary()
|
||||||
|
|
||||||
|
self.bump_cache_version()
|
||||||
|
|
||||||
def _log_duplicate_filename_summary(self) -> None:
|
def _log_duplicate_filename_summary(self) -> None:
|
||||||
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
"""Log a batched summary of duplicate filename conflicts once per scan."""
|
||||||
# Duplicate filename detection is only relevant for LoRAs, which use
|
# Duplicate filename detection is only relevant for LoRAs, which use
|
||||||
@@ -1202,7 +1348,7 @@ class ModelScanner:
|
|||||||
|
|
||||||
async def _sync_download_history(
|
async def _sync_download_history(
|
||||||
self,
|
self,
|
||||||
raw_data: List[Mapping[str, Any]],
|
raw_data: Sequence[Mapping[str, Any]],
|
||||||
*,
|
*,
|
||||||
source: str,
|
source: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1251,7 +1397,7 @@ class ModelScanner:
|
|||||||
) -> CacheBuildResult:
|
) -> CacheBuildResult:
|
||||||
"""Collect metadata for all model files."""
|
"""Collect metadata for all model files."""
|
||||||
|
|
||||||
raw_data: List[Dict] = []
|
raw_data: List[Dict[str, Any]] = []
|
||||||
hash_index = ModelHashIndex()
|
hash_index = ModelHashIndex()
|
||||||
tags_count: Dict[str, int] = {}
|
tags_count: Dict[str, int] = {}
|
||||||
excluded_models: List[str] = []
|
excluded_models: List[str] = []
|
||||||
@@ -1315,6 +1461,8 @@ class ModelScanner:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
result = validation_result.entry
|
result = validation_result.entry
|
||||||
|
if result is None:
|
||||||
|
continue
|
||||||
|
|
||||||
self._ensure_license_flags(result)
|
self._ensure_license_flags(result)
|
||||||
raw_data.append(result)
|
raw_data.append(result)
|
||||||
@@ -1322,7 +1470,7 @@ class ModelScanner:
|
|||||||
sha_value = result.get('sha256')
|
sha_value = result.get('sha256')
|
||||||
model_path = result.get('file_path')
|
model_path = result.get('file_path')
|
||||||
if sha_value and model_path:
|
if sha_value and model_path:
|
||||||
hash_index.add_entry(sha_value.lower(), model_path)
|
hash_index.add_entry(sha_value.lower(), model_path, result.get('autov3') or None)
|
||||||
|
|
||||||
for tag in result.get('tags') or []:
|
for tag in result.get('tags') or []:
|
||||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||||
@@ -1332,6 +1480,8 @@ class ModelScanner:
|
|||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
return
|
return
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
|
if _is_excluded_dir(entry.name):
|
||||||
|
continue
|
||||||
await scan_recursive(entry.path, root_path, visited_paths)
|
await scan_recursive(entry.path, root_path, visited_paths)
|
||||||
except Exception as entry_error:
|
except Exception as entry_error:
|
||||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||||
@@ -1354,7 +1504,7 @@ class ModelScanner:
|
|||||||
excluded_models=excluded_models
|
excluded_models=excluded_models
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
|
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
|
||||||
"""Add a model to the cache
|
"""Add a model to the cache
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1367,7 +1517,8 @@ class ModelScanner:
|
|||||||
try:
|
try:
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
await self.get_cached_data()
|
await self.get_cached_data()
|
||||||
|
assert self._cache is not None
|
||||||
|
|
||||||
# Update folder in metadata
|
# Update folder in metadata
|
||||||
metadata_dict['folder'] = folder
|
metadata_dict['folder'] = folder
|
||||||
|
|
||||||
@@ -1391,14 +1542,19 @@ class ModelScanner:
|
|||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
# Update the hash index
|
# Update the hash index
|
||||||
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
self._hash_index.add_entry(
|
||||||
|
metadata_dict['sha256'],
|
||||||
|
metadata_dict['file_path'],
|
||||||
|
metadata_dict.get('autov3') or None,
|
||||||
|
)
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
|
self.bump_cache_version()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding model to cache: {e}")
|
logger.error(f"Error adding model to cache: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def move_model(self, source_path: str, target_path: str) -> Optional[str]:
|
async def move_model(self, source_path: str, target_path: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Move a model and its associated files to a new location
|
"""Move a model and its associated files to a new location
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1432,7 +1588,7 @@ class ModelScanner:
|
|||||||
# Check for filename conflicts and auto-rename if necessary
|
# Check for filename conflicts and auto-rename if necessary
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
final_filename = BaseModelMetadata.generate_unique_filename(
|
final_filename = BaseModelMetadata.generate_unique_filename(
|
||||||
target_path, base_name, file_ext, get_source_hash
|
target_path, base_name, file_ext, lambda: get_source_hash() or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
target_file = os.path.join(target_path, final_filename).replace(os.sep, '/')
|
target_file = os.path.join(target_path, final_filename).replace(os.sep, '/')
|
||||||
@@ -1480,7 +1636,7 @@ class ModelScanner:
|
|||||||
logger.error(f"Error moving associated file {source_file}: {e}")
|
logger.error(f"Error moving associated file {source_file}: {e}")
|
||||||
|
|
||||||
# Handle metadata file specially to update paths
|
# Handle metadata file specially to update paths
|
||||||
if source_metadata and os.path.exists(source_metadata):
|
if source_metadata and moved_metadata_path and os.path.exists(source_metadata):
|
||||||
try:
|
try:
|
||||||
shutil.move(source_metadata, moved_metadata_path)
|
shutil.move(source_metadata, moved_metadata_path)
|
||||||
metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
|
metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
|
||||||
@@ -1498,7 +1654,7 @@ class ModelScanner:
|
|||||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Dict:
|
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Update file paths in metadata file"""
|
"""Update file paths in metadata file"""
|
||||||
try:
|
try:
|
||||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||||
@@ -1524,7 +1680,7 @@ class ModelScanner:
|
|||||||
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
|
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Dict, recalculate_type: bool = False) -> Union[bool, Dict]:
|
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Optional[Dict[str, Any]], recalculate_type: bool = False) -> Union[bool, Dict[str, Any]]:
|
||||||
"""Update cache after a model has been moved or modified"""
|
"""Update cache after a model has been moved or modified"""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -1547,6 +1703,7 @@ class ModelScanner:
|
|||||||
]
|
]
|
||||||
|
|
||||||
cache_modified = bool(existing_item) or bool(metadata)
|
cache_modified = bool(existing_item) or bool(metadata)
|
||||||
|
cache_entry: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
normalized_new_path = new_path.replace(os.sep, '/')
|
normalized_new_path = new_path.replace(os.sep, '/')
|
||||||
@@ -1578,7 +1735,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
sha_value = cache_entry.get('sha256')
|
sha_value = cache_entry.get('sha256')
|
||||||
if sha_value:
|
if sha_value:
|
||||||
self._hash_index.add_entry(sha_value.lower(), normalized_new_path)
|
self._hash_index.add_entry(
|
||||||
|
sha_value.lower(),
|
||||||
|
normalized_new_path,
|
||||||
|
cache_entry.get('autov3') or None,
|
||||||
|
)
|
||||||
|
|
||||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||||
@@ -1592,8 +1753,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
if cache_modified:
|
if cache_modified:
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
|
self.bump_cache_version()
|
||||||
|
|
||||||
return cache_entry if metadata else True
|
if metadata and cache_entry is not None:
|
||||||
|
return cache_entry
|
||||||
|
return True
|
||||||
|
|
||||||
async def sync_cache_from_metadata(
|
async def sync_cache_from_metadata(
|
||||||
self, file_path: str, metadata_dict: Dict[str, Any]
|
self, file_path: str, metadata_dict: Dict[str, Any]
|
||||||
@@ -1716,10 +1880,11 @@ class ModelScanner:
|
|||||||
# ---- In-place update of the cache entry ----
|
# ---- In-place update of the cache entry ----
|
||||||
existing_entry.clear()
|
existing_entry.clear()
|
||||||
existing_entry.update(desired_entry)
|
existing_entry.update(desired_entry)
|
||||||
|
self.bump_cache_version()
|
||||||
|
|
||||||
# ---- Incremental tag count update ----
|
# ---- Incremental tag count update ----
|
||||||
new_tags: set = set(desired_entry.get("tags") or [])
|
new_tags: set[str] = set(desired_entry.get("tags") or [])
|
||||||
old_tag_set: set = set(old_tags)
|
old_tag_set: set[str] = set(old_tags)
|
||||||
for tag in old_tag_set - new_tags:
|
for tag in old_tag_set - new_tags:
|
||||||
current = self._tags_count.get(tag, 0)
|
current = self._tags_count.get(tag, 0)
|
||||||
if current <= 1:
|
if current <= 1:
|
||||||
@@ -1736,7 +1901,11 @@ class ModelScanner:
|
|||||||
if old_sha:
|
if old_sha:
|
||||||
self._hash_index.remove_by_path(file_path)
|
self._hash_index.remove_by_path(file_path)
|
||||||
if new_sha:
|
if new_sha:
|
||||||
self._hash_index.add_entry(new_sha, file_path)
|
self._hash_index.add_entry(
|
||||||
|
new_sha,
|
||||||
|
file_path,
|
||||||
|
desired_entry.get('autov3') or None,
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Incremental version index update ----
|
# ---- Incremental version index update ----
|
||||||
new_civitai = desired_entry.get("civitai")
|
new_civitai = desired_entry.get("civitai")
|
||||||
@@ -1787,6 +1956,75 @@ class ModelScanner:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def update_autov3_for_model(self, model_type: str, file_path: str, autov3: str) -> bool:
|
||||||
|
"""Persist an AutoV3 hash for a single model (single write path used by the backfill service).
|
||||||
|
|
||||||
|
Locates the in-memory cache entry by ``file_path`` and updates only its
|
||||||
|
``autov3`` field: the in-memory hash index, the SQLite snapshot via
|
||||||
|
:meth:`PersistentModelCache.update_single_model`, and the
|
||||||
|
``.metadata.json`` sidecar. sha256, tags, and every other field are
|
||||||
|
left untouched, so the persistent delta only ever differs in autov3.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` when the entry was found and updated, ``False`` otherwise.
|
||||||
|
Never raises — failures are logged and swallowed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._cache is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
entry = next(
|
||||||
|
(item for item in self._cache.raw_data if item.get('file_path') == file_path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Normalize once so the memory entry, sidecar, and SQLite row agree.
|
||||||
|
autov3 = (autov3 or "").lower()
|
||||||
|
|
||||||
|
# Capture the pre-mutation state so update_single_model only sees
|
||||||
|
# an autov3 delta between old and new.
|
||||||
|
old_item = dict(entry)
|
||||||
|
|
||||||
|
entry['autov3'] = autov3 or ''
|
||||||
|
|
||||||
|
# Prefer add_entry when a sha256 is known so the sha256 and autov3
|
||||||
|
# maps stay in sync; fall back to an autov3-only registration.
|
||||||
|
sha_value = entry.get('sha256')
|
||||||
|
checked_autov3 = entry.get('autov3') or None
|
||||||
|
if sha_value:
|
||||||
|
self._hash_index.add_entry(sha_value.lower(), file_path, checked_autov3)
|
||||||
|
elif checked_autov3:
|
||||||
|
self._hash_index.add_autov3(checked_autov3, file_path)
|
||||||
|
|
||||||
|
persistent = getattr(self, '_persistent_cache', None)
|
||||||
|
if persistent is not None:
|
||||||
|
await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
persistent.update_single_model,
|
||||||
|
model_type,
|
||||||
|
entry,
|
||||||
|
old_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sidecar write-back: JSON null encodes the checked-unavailable
|
||||||
|
# state. Skip silently when the sidecar does not exist.
|
||||||
|
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||||
|
if os.path.exists(metadata_path):
|
||||||
|
with open(metadata_path, 'r', encoding='utf-8') as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
payload['autov3'] = entry['autov3'] or None
|
||||||
|
await MetadataManager.save_metadata(metadata_path, payload)
|
||||||
|
|
||||||
|
self.bump_cache_version()
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to update AutoV3 for %s: %s", file_path, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cache_entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
def _cache_entries_differ(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
|
||||||
"""Return ``True`` when two cache-entry dicts differ in any field.
|
"""Return ``True`` when two cache-entry dicts differ in any field.
|
||||||
@@ -1846,7 +2084,7 @@ class ModelScanner:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
"""Get top tags sorted by count. If limit is 0, return all tags."""
|
"""Get top tags sorted by count. If limit is 0, return all tags."""
|
||||||
await self.get_cached_data()
|
await self.get_cached_data()
|
||||||
|
|
||||||
@@ -1862,7 +2100,7 @@ class ModelScanner:
|
|||||||
|
|
||||||
async def search_tags(
|
async def search_tags(
|
||||||
self, query: str, limit: int = 50
|
self, query: str, limit: int = 50
|
||||||
) -> List[Dict[str, any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Search tags by case-insensitive substring match, sorted by count.
|
"""Search tags by case-insensitive substring match, sorted by count.
|
||||||
|
|
||||||
If query is empty, behaves like get_top_tags (returns top ``limit``
|
If query is empty, behaves like get_top_tags (returns top ``limit``
|
||||||
@@ -1885,7 +2123,7 @@ class ModelScanner:
|
|||||||
return matched
|
return matched
|
||||||
return matched[:limit]
|
return matched[:limit]
|
||||||
|
|
||||||
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
|
async def get_base_models(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
"""Get base models sorted by count. If limit is 0, return all."""
|
"""Get base models sorted by count. If limit is 0, return all."""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -1966,7 +2204,7 @@ class ModelScanner:
|
|||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
async def bulk_delete_models(self, file_paths: List[str]) -> Dict:
|
async def bulk_delete_models(self, file_paths: List[str]) -> Dict[str, Any]:
|
||||||
"""Delete multiple models and update cache in a batch operation
|
"""Delete multiple models and update cache in a batch operation
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1994,6 +2232,11 @@ class ModelScanner:
|
|||||||
# Track deleted models to update cache once
|
# Track deleted models to update cache once
|
||||||
deleted_models = []
|
deleted_models = []
|
||||||
|
|
||||||
|
# Stage each file into the pending-delete staging area and merge
|
||||||
|
# all per-file batches into ONE batch for the whole bulk action.
|
||||||
|
pending_delete_service = await get_pending_delete_service()
|
||||||
|
batch_ids: List[str] = []
|
||||||
|
|
||||||
for file_path in file_paths:
|
for file_path in file_paths:
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
|
||||||
@@ -2006,11 +2249,35 @@ class ModelScanner:
|
|||||||
base_name = os.path.basename(file_path)
|
base_name = os.path.basename(file_path)
|
||||||
file_name, main_extension = os.path.splitext(base_name)
|
file_name, main_extension = os.path.splitext(base_name)
|
||||||
|
|
||||||
deleted_files = await delete_model_artifacts(
|
# Snapshot the cache entry BEFORE the cache mutation that
|
||||||
target_dir,
|
# runs after the loop - the manifest needs it for undo.
|
||||||
file_name,
|
cached_entry = None
|
||||||
|
if cache is not None:
|
||||||
|
cached_entry = next(
|
||||||
|
(item for item in cache.raw_data if item.get('file_path') == file_path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_id = await pending_delete_service.stage_model_delete(
|
||||||
|
scanner=self,
|
||||||
|
target_dir=target_dir,
|
||||||
|
file_name=file_name,
|
||||||
main_extension=main_extension,
|
main_extension=main_extension,
|
||||||
|
original_file_path=file_path,
|
||||||
|
cached_entry=cached_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if batch_id is not None:
|
||||||
|
# Artifacts were renamed into staging: the main file is
|
||||||
|
# gone from its original location.
|
||||||
|
batch_ids.append(batch_id)
|
||||||
|
deleted_files = [file_path]
|
||||||
|
else:
|
||||||
|
deleted_files = await delete_model_artifacts(
|
||||||
|
target_dir,
|
||||||
|
file_name,
|
||||||
|
main_extension=main_extension,
|
||||||
|
)
|
||||||
|
|
||||||
if deleted_files:
|
if deleted_files:
|
||||||
deleted_models.append(file_path)
|
deleted_models.append(file_path)
|
||||||
@@ -2034,6 +2301,18 @@ class ModelScanner:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Merge every staged per-file batch into ONE undoable batch. On a
|
||||||
|
# merge failure (cross-volume EXDEV etc.) the response falls back
|
||||||
|
# to the constituent batch_ids array so the frontend can undo them
|
||||||
|
# sequentially.
|
||||||
|
batch_field: Dict[str, Any] = {}
|
||||||
|
if batch_ids:
|
||||||
|
merged_id = await pending_delete_service.merge_batches(batch_ids)
|
||||||
|
if merged_id is not None:
|
||||||
|
batch_field['batch_id'] = merged_id
|
||||||
|
else:
|
||||||
|
batch_field['batch_ids'] = list(batch_ids)
|
||||||
|
|
||||||
# Batch update cache if any models were deleted
|
# Batch update cache if any models were deleted
|
||||||
if deleted_models:
|
if deleted_models:
|
||||||
# Update the cache in a batch operation
|
# Update the cache in a batch operation
|
||||||
@@ -2045,7 +2324,8 @@ class ModelScanner:
|
|||||||
'total_deleted': total_deleted,
|
'total_deleted': total_deleted,
|
||||||
'total_attempted': len(file_paths),
|
'total_attempted': len(file_paths),
|
||||||
'cache_updated': cache_updated,
|
'cache_updated': cache_updated,
|
||||||
'results': results
|
'results': results,
|
||||||
|
**batch_field
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2114,6 +2394,8 @@ class ModelScanner:
|
|||||||
|
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
|
|
||||||
|
self.bump_cache_version()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2164,7 +2446,7 @@ class ModelScanner:
|
|||||||
logger.error(f"Error checking model version existence: {e}")
|
logger.error(f"Error checking model version existence: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def get_model_versions_by_id(self, model_id: int) -> List[Dict]:
|
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
|
||||||
"""Get all versions of a model by its ID
|
"""Get all versions of a model by its ID
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ logger = logging.getLogger(__name__)
|
|||||||
class ModelServiceFactory:
|
class ModelServiceFactory:
|
||||||
"""Factory for managing model services and routes"""
|
"""Factory for managing model services and routes"""
|
||||||
|
|
||||||
_services: Dict[str, Type] = {}
|
_services: Dict[str, Type[Any]] = {}
|
||||||
_routes: Dict[str, Type] = {}
|
_routes: Dict[str, Type[Any]] = {}
|
||||||
_initialized_services: Dict[str, Any] = {}
|
_initialized_services: Dict[str, Any] = {}
|
||||||
_initialized_routes: Dict[str, Any] = {}
|
_initialized_routes: Dict[str, Any] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def register_model_type(cls, model_type: str, service_class: Type, route_class: Type):
|
def register_model_type(cls, model_type: str, service_class: Type[Any], route_class: Type[Any]):
|
||||||
"""Register a new model type with its service and route classes
|
"""Register a new model type with its service and route classes
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -24,7 +24,7 @@ class ModelServiceFactory:
|
|||||||
cls._routes[model_type] = route_class
|
cls._routes[model_type] = route_class
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_service_class(cls, model_type: str) -> Type:
|
def get_service_class(cls, model_type: str) -> Type[Any]:
|
||||||
"""Get service class for a model type
|
"""Get service class for a model type
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -41,7 +41,7 @@ class ModelServiceFactory:
|
|||||||
return cls._services[model_type]
|
return cls._services[model_type]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_route_class(cls, model_type: str) -> Type:
|
def get_route_class(cls, model_type: str) -> Type[Any]:
|
||||||
"""Get route class for a model type
|
"""Get route class for a model type
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -87,7 +87,7 @@ class ModelServiceFactory:
|
|||||||
logger.error(f"Failed to setup routes for {model_type}: {e}", exc_info=True)
|
logger.error(f"Failed to setup routes for {model_type}: {e}", exc_info=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_registered_types(cls) -> list:
|
def get_registered_types(cls) -> list[str]:
|
||||||
"""Get list of all registered model types
|
"""Get list of all registered model types
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
"""Service for tracking remote model version updates."""
|
"""Service for tracking remote model version updates."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -336,9 +340,9 @@ class ModelUpdateService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .persistent_model_cache import get_persistent_cache
|
from .persistent_model_cache import PersistentModelCache
|
||||||
|
|
||||||
legacy_path = get_persistent_cache(self._library_name).get_database_path()
|
legacy_path = PersistentModelCache.get_default(self._library_name).get_database_path()
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -735,7 +739,7 @@ class ModelUpdateService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
results: Dict[int, ModelUpdateRecord] = {}
|
results: Dict[int, ModelUpdateRecord] = {}
|
||||||
prefetched: Dict[int, Mapping] = {}
|
prefetched: Dict[int, Mapping[Any, Any]] = {}
|
||||||
|
|
||||||
fetch_targets: List[int] = []
|
fetch_targets: List[int] = []
|
||||||
if metadata_provider and local_versions:
|
if metadata_provider and local_versions:
|
||||||
@@ -834,7 +838,7 @@ class ModelUpdateService:
|
|||||||
model_id: int,
|
model_id: int,
|
||||||
version_ids: Sequence[int],
|
version_ids: Sequence[int],
|
||||||
*,
|
*,
|
||||||
version_info: Optional[Mapping] = None,
|
version_info: Optional[Mapping[str, Any]] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
"""Persist a new set of in-library version identifiers."""
|
"""Persist a new set of in-library version identifiers."""
|
||||||
|
|
||||||
@@ -954,7 +958,11 @@ class ModelUpdateService:
|
|||||||
records = self._get_records_bulk(model_type, normalized_ids)
|
records = self._get_records_bulk(model_type, normalized_ids)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model_id: records.get(model_id).has_update(hide_early_access=hide_early_access) if records.get(model_id) else False
|
model_id: (
|
||||||
|
records[model_id].has_update(hide_early_access=hide_early_access)
|
||||||
|
if model_id in records
|
||||||
|
else False
|
||||||
|
)
|
||||||
for model_id in normalized_ids
|
for model_id in normalized_ids
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +988,7 @@ class ModelUpdateService:
|
|||||||
metadata_provider,
|
metadata_provider,
|
||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
prefetched_response: Optional[Mapping] = None,
|
prefetched_response: Optional[Mapping[str, Any]] = None,
|
||||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||||
) -> Optional[ModelUpdateRecord]:
|
) -> Optional[ModelUpdateRecord]:
|
||||||
normalized_local = self._normalize_sequence(local_versions)
|
normalized_local = self._normalize_sequence(local_versions)
|
||||||
@@ -1010,7 +1018,7 @@ class ModelUpdateService:
|
|||||||
fallback_attempted = False
|
fallback_attempted = False
|
||||||
fallback_error_message: Optional[str] = None
|
fallback_error_message: Optional[str] = None
|
||||||
mark_model_as_ignored = False
|
mark_model_as_ignored = False
|
||||||
response: Optional[Mapping] = None
|
response: Optional[Mapping[str, Any]] = None
|
||||||
if metadata_provider and should_fetch:
|
if metadata_provider and should_fetch:
|
||||||
response = prefetched_response
|
response = prefetched_response
|
||||||
if response is None:
|
if response is None:
|
||||||
@@ -1122,7 +1130,7 @@ class ModelUpdateService:
|
|||||||
async def _enrich_version_entries(
|
async def _enrich_version_entries(
|
||||||
self,
|
self,
|
||||||
metadata_provider,
|
metadata_provider,
|
||||||
responses_by_model_id: Dict[int, Mapping],
|
responses_by_model_id: Dict[int, Mapping[Any, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
|
"""Enrich version entries with ``usageControl`` via batch hash endpoint.
|
||||||
|
|
||||||
@@ -1151,7 +1159,7 @@ class ModelUpdateService:
|
|||||||
all_hashes = list(version_ids_by_hash.keys())
|
all_hashes = list(version_ids_by_hash.keys())
|
||||||
BATCH_SIZE = 100
|
BATCH_SIZE = 100
|
||||||
|
|
||||||
enrichment: Dict[int, Dict] = {}
|
enrichment: Dict[int, Dict[str, Any]] = {}
|
||||||
try:
|
try:
|
||||||
for start in range(0, len(all_hashes), BATCH_SIZE):
|
for start in range(0, len(all_hashes), BATCH_SIZE):
|
||||||
batch = all_hashes[start : start + BATCH_SIZE]
|
batch = all_hashes[start : start + BATCH_SIZE]
|
||||||
@@ -1208,7 +1216,7 @@ class ModelUpdateService:
|
|||||||
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
|
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _collect_hashes_from_response(response: Mapping) -> Dict[int, str]:
|
def _collect_hashes_from_response(response: Mapping[str, Any]) -> Dict[int, str]:
|
||||||
"""Extract ``{version_id: sha256}`` from a model-level API response.
|
"""Extract ``{version_id: sha256}`` from a model-level API response.
|
||||||
|
|
||||||
Returns an empty dict if the response structure is unexpected.
|
Returns an empty dict if the response structure is unexpected.
|
||||||
@@ -1229,7 +1237,7 @@ class ModelUpdateService:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_sha256_from_version_entry(entry: Mapping) -> Optional[str]:
|
def _extract_sha256_from_version_entry(entry: Mapping[str, Any]) -> Optional[str]:
|
||||||
"""Return the SHA256 hash from the primary model file of a version entry."""
|
"""Return the SHA256 hash from the primary model file of a version entry."""
|
||||||
files = entry.get("files")
|
files = entry.get("files")
|
||||||
if not isinstance(files, list):
|
if not isinstance(files, list):
|
||||||
@@ -1253,22 +1261,19 @@ class ModelUpdateService:
|
|||||||
self,
|
self,
|
||||||
metadata_provider,
|
metadata_provider,
|
||||||
model_ids: Sequence[int],
|
model_ids: Sequence[int],
|
||||||
) -> Dict[int, Mapping]:
|
) -> Dict[int, Mapping[Any, Any]]:
|
||||||
"""Fetch model metadata in batches of up to 100 ids."""
|
"""Fetch model metadata in batches of up to 100 ids."""
|
||||||
|
|
||||||
BATCH_SIZE = 100
|
BATCH_SIZE = 100
|
||||||
normalized = self._normalize_sequence(model_ids)
|
normalized = self._normalize_sequence(model_ids)
|
||||||
if not normalized:
|
provider = metadata_provider
|
||||||
|
if not normalized or provider is None:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
aggregated: Dict[int, Mapping] = {}
|
aggregated: Dict[int, Mapping[Any, Any]] = {}
|
||||||
total_ids = len(normalized)
|
total_ids = len(normalized)
|
||||||
total_batches = (total_ids + BATCH_SIZE - 1) // BATCH_SIZE
|
total_batches = (total_ids + BATCH_SIZE - 1) // BATCH_SIZE
|
||||||
provider_name = (
|
provider_name = provider.__class__.__name__
|
||||||
metadata_provider.__class__.__name__
|
|
||||||
if metadata_provider is not None
|
|
||||||
else "unknown"
|
|
||||||
)
|
|
||||||
for batch_index, start in enumerate(range(0, total_ids, BATCH_SIZE), start=1):
|
for batch_index, start in enumerate(range(0, total_ids, BATCH_SIZE), start=1):
|
||||||
chunk = normalized[start : start + BATCH_SIZE]
|
chunk = normalized[start : start + BATCH_SIZE]
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1279,7 +1284,7 @@ class ModelUpdateService:
|
|||||||
provider_name,
|
provider_name,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await metadata_provider.get_model_versions_bulk(chunk)
|
response = await provider.get_model_versions_bulk(chunk)
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
raise
|
raise
|
||||||
if response is None:
|
if response is None:
|
||||||
@@ -1356,7 +1361,7 @@ class ModelUpdateService:
|
|||||||
model_type: Optional[str] = None,
|
model_type: Optional[str] = None,
|
||||||
model_id: Optional[int] = None,
|
model_id: Optional[int] = None,
|
||||||
last_checked_at: Optional[float] = None,
|
last_checked_at: Optional[float] = None,
|
||||||
version_info: Optional[Mapping] = None,
|
version_info: Optional[Mapping[str, Any]] = None,
|
||||||
) -> ModelUpdateRecord:
|
) -> ModelUpdateRecord:
|
||||||
local_set = set(normalized_local)
|
local_set = set(normalized_local)
|
||||||
# When folder-filtering, also consider versions in other folders
|
# When folder-filtering, also consider versions in other folders
|
||||||
@@ -1578,7 +1583,7 @@ class ModelUpdateService:
|
|||||||
if not isinstance(files, Iterable):
|
if not isinstance(files, Iterable):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def parse_size(entry: Mapping) -> Optional[int]:
|
def parse_size(entry: Mapping[str, Any]) -> Optional[int]:
|
||||||
size_kb = entry.get("sizeKB")
|
size_kb = entry.get("sizeKB")
|
||||||
if size_kb is None:
|
if size_kb is None:
|
||||||
return None
|
return None
|
||||||
@@ -1664,8 +1669,8 @@ class ModelUpdateService:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
ids = list(model_ids)
|
ids = list(model_ids)
|
||||||
status_rows: list = []
|
status_rows: list[sqlite3.Row] = []
|
||||||
version_rows: list = []
|
version_rows: list[sqlite3.Row] = []
|
||||||
|
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
|
for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user