mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 03:24:09 -03:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8237e5f9ea | ||
|
|
aa75986178 | ||
|
|
b887922055 | ||
|
|
68fa0f29c7 | ||
|
|
d9d362c9c9 | ||
|
|
d0bc4be0dc | ||
|
|
420530f532 | ||
|
|
3001f0f0ef | ||
|
|
b2a1307d23 | ||
|
|
64da845a58 |
@@ -1,47 +1,145 @@
|
||||
---
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- 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 DevTools MCP connected
|
||||
- `ss` (or `lsof`/`netstat`) available for port checks: `ss -tlnp`
|
||||
|
||||
## Quick Start Workflow
|
||||
## Port Selection
|
||||
|
||||
### 1. Start LoRa Manager Standalone
|
||||
|
||||
```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
|
||||
`8188` is only the *default candidate*. Verify it is actually free before every run:
|
||||
|
||||
```bash
|
||||
# Chrome with remote debugging on port 9222
|
||||
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras
|
||||
# Is anything listening on 8188?
|
||||
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:
|
||||
- Take snapshots: `take_snapshot`
|
||||
@@ -56,7 +154,7 @@ Use Chrome DevTools MCP tools to:
|
||||
|
||||
```python
|
||||
# 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(text="LoRAs", timeout=10000)
|
||||
@@ -68,9 +166,10 @@ snapshot = take_snapshot()
|
||||
### Pattern: Restart Server for Configuration Changes
|
||||
|
||||
```python
|
||||
# Stop current server (if running)
|
||||
# Start with new configuration
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188 --restart
|
||||
# Stop current server (if running), start with new configuration.
|
||||
# --restart only kills the E2E server this script started before (via its pidfile);
|
||||
# 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
|
||||
navigate_page(type="reload", ignoreCache=True)
|
||||
@@ -130,24 +229,96 @@ click(uid="modal-submit-button")
|
||||
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
|
||||
|
||||
### scripts/start_server.py
|
||||
|
||||
Starts or restarts the LoRa Manager standalone server.
|
||||
Starts or restarts the LoRa Manager standalone server for E2E testing.
|
||||
|
||||
```bash
|
||||
python scripts/start_server.py [--port PORT] [--restart] [--wait]
|
||||
python scripts/start_server.py [--port PORT] [--restart] [--wait] [--timeout SECONDS] [--detach]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--port`: Server port (default: 8188)
|
||||
- `--restart`: Kill existing server before starting
|
||||
- `--wait`: Wait for server to be ready before exiting
|
||||
- `--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 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 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
|
||||
|
||||
Polls server until ready or timeout.
|
||||
Polls the server until ready or timeout.
|
||||
|
||||
```bash
|
||||
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
|
||||
@@ -196,6 +367,7 @@ results = performance_stop_trace()
|
||||
## Cleanup
|
||||
|
||||
Always ensure proper cleanup after tests:
|
||||
1. Stop the standalone server
|
||||
2. Close browser pages (keep at least one open)
|
||||
3. Clear temporary data if needed
|
||||
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).
|
||||
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.
|
||||
|
||||
> **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
|
||||
|
||||
```python
|
||||
# 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
|
||||
navigate_page(type="reload", ignoreCache=True)
|
||||
@@ -179,7 +181,7 @@ pages = list_pages()
|
||||
select_page(pageId=0, bringToFront=True)
|
||||
|
||||
# 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(pageId=1)
|
||||
@@ -261,7 +263,7 @@ drag(from_uid="draggable-item", to_uid="drop-zone")
|
||||
### Verify LoRA Cards Loaded
|
||||
|
||||
```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)
|
||||
|
||||
# Check if cards loaded
|
||||
@@ -322,3 +324,37 @@ navigate_page(type="reload")
|
||||
errors = list_console_messages(types=["error"])
|
||||
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.
|
||||
|
||||
> **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
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
3. Take snapshot to verify:
|
||||
- Header with "LoRAs" title is visible
|
||||
@@ -134,7 +142,7 @@ evaluate_script(function="""
|
||||
**Objective**: Verify recipes page loads and displays recipes.
|
||||
|
||||
**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
|
||||
3. Take snapshot
|
||||
|
||||
@@ -176,7 +184,7 @@ evaluate_script(function="""
|
||||
**Objective**: Verify settings page displays correctly.
|
||||
|
||||
**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
|
||||
3. Take snapshot
|
||||
|
||||
@@ -190,7 +198,7 @@ evaluate_script(function="""
|
||||
1. Navigate to settings page
|
||||
2. Change a setting (e.g., default view mode)
|
||||
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
|
||||
6. Navigate to settings
|
||||
|
||||
|
||||
@@ -8,186 +8,208 @@ This script shows how to:
|
||||
3. Verify functionality end-to-end
|
||||
|
||||
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 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():
|
||||
"""Run example E2E test flow."""
|
||||
|
||||
|
||||
print("=" * 60)
|
||||
print("LoRa Manager E2E Test Example")
|
||||
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...")
|
||||
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,
|
||||
text=True
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Failed to start server: {result.stderr}")
|
||||
return 1
|
||||
print("Server ready!")
|
||||
|
||||
|
||||
# Step 2: Open Chrome (manual step - show command)
|
||||
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)")
|
||||
|
||||
|
||||
# Step 3: Navigate and verify page load
|
||||
print("\n[3/5] Page Load Verification:")
|
||||
print("""
|
||||
print(
|
||||
f"""
|
||||
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)
|
||||
3. snapshot = take_snapshot()
|
||||
""")
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
# Step 4: Test search functionality
|
||||
print("\n[4/5] Search Functionality Test:")
|
||||
print("""
|
||||
print(
|
||||
"""
|
||||
MCP Commands to execute:
|
||||
1. fill(uid="search-input", value="test")
|
||||
2. press_key(key="Enter")
|
||||
3. wait_for(text="Results", timeout=5000)
|
||||
4. result = evaluate_script(function="""
|
||||
4. result = evaluate_script(function=`
|
||||
() => {
|
||||
const cards = document.querySelectorAll('.lora-card');
|
||||
return { count: cards.length };
|
||||
}
|
||||
""")
|
||||
""")
|
||||
|
||||
`)
|
||||
"""
|
||||
)
|
||||
|
||||
# Step 5: Verify API
|
||||
print("\n[5/5] API Verification:")
|
||||
print("""
|
||||
print(
|
||||
"""
|
||||
MCP Commands to execute:
|
||||
1. api_result = evaluate_script(function="""
|
||||
1. api_result = evaluate_script(function=`
|
||||
async () => {
|
||||
const response = await fetch('/loras/api/list');
|
||||
const data = await response.json();
|
||||
return { count: data.length, status: response.status };
|
||||
}
|
||||
""")
|
||||
`)
|
||||
2. Verify api_result['status'] == 200
|
||||
""")
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Test flow completed!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def example_restart_flow():
|
||||
"""Example: Testing configuration change that requires restart."""
|
||||
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example: Server Restart Flow")
|
||||
print("=" * 60)
|
||||
|
||||
print("""
|
||||
|
||||
print(
|
||||
f"""
|
||||
Scenario: Change setting and verify after restart
|
||||
|
||||
|
||||
Steps:
|
||||
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)
|
||||
- fill(uid="theme-select", value="dark")
|
||||
- click(uid="save-settings-button")
|
||||
|
||||
|
||||
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
|
||||
- navigate_page(type="reload", ignoreCache=True)
|
||||
- wait_for(text="LoRAs", timeout=15000)
|
||||
|
||||
|
||||
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")
|
||||
- assert theme == "dark"
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def example_modal_interaction():
|
||||
"""Example: Testing modal dialog interaction."""
|
||||
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example: Modal Dialog Interaction")
|
||||
print("=" * 60)
|
||||
|
||||
print("""
|
||||
|
||||
print(
|
||||
"""
|
||||
Scenario: Add new LoRA via modal
|
||||
|
||||
|
||||
Steps:
|
||||
1. Open modal
|
||||
- click(uid="add-lora-button")
|
||||
- wait_for(text="Add LoRA", timeout=3000)
|
||||
|
||||
|
||||
2. Fill form
|
||||
- fill_form(elements=[
|
||||
{"uid": "lora-name", "value": "Test Character"},
|
||||
{"uid": "lora-path", "value": "/models/test.safetensors"},
|
||||
])
|
||||
|
||||
|
||||
3. Submit
|
||||
- click(uid="modal-submit-button")
|
||||
|
||||
|
||||
4. Verify success
|
||||
- wait_for(text="Successfully added", timeout=5000)
|
||||
- snapshot = take_snapshot()
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def example_network_monitoring():
|
||||
"""Example: Network request monitoring."""
|
||||
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example: Network Request Monitoring")
|
||||
print("=" * 60)
|
||||
|
||||
print("""
|
||||
|
||||
print(
|
||||
f"""
|
||||
Scenario: Verify API calls during user interaction
|
||||
|
||||
|
||||
Steps:
|
||||
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
|
||||
- fill(uid="search-input", value="character")
|
||||
- press_key(key="Enter")
|
||||
|
||||
|
||||
3. List network requests
|
||||
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
|
||||
|
||||
|
||||
4. Find search API call
|
||||
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
|
||||
- assert len(search_requests) > 0, "Search API was not called"
|
||||
|
||||
|
||||
5. Get request details
|
||||
- if search_requests:
|
||||
details = get_network_request(reqid=search_requests[0]["reqid"])
|
||||
- Verify request method, response status, etc.
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("LoRa Manager E2E Test Examples\n")
|
||||
print("This script demonstrates E2E testing patterns.\n")
|
||||
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
|
||||
|
||||
|
||||
run_test()
|
||||
example_restart_flow()
|
||||
example_modal_interaction()
|
||||
example_network_monitoring()
|
||||
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("All examples shown!")
|
||||
print("=" * 60)
|
||||
|
||||
@@ -1,15 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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 os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import signal
|
||||
import os
|
||||
|
||||
PIDFILE_PREFIX = "/tmp/lora-manager-e2e-server"
|
||||
|
||||
|
||||
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]:
|
||||
@@ -19,7 +82,7 @@ def find_server_process(port: int) -> list[int]:
|
||||
["lsof", "-ti", f":{port}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
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"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
check=False,
|
||||
)
|
||||
pids = []
|
||||
for line in result.stdout.split("\n"):
|
||||
@@ -49,30 +112,48 @@ def find_server_process(port: int) -> list[int]:
|
||||
return []
|
||||
|
||||
|
||||
def kill_server(port: int) -> None:
|
||||
"""Kill processes using the specified port."""
|
||||
pids = find_server_process(port)
|
||||
def describe_processes(pids: list[int]) -> str:
|
||||
"""Human-readable description of a pid list (pid + command line)."""
|
||||
descriptions = []
|
||||
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:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
print(f"Sent SIGTERM to process {pid}")
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
# 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
|
||||
pids = find_server_process(port)
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
print(f"Sent SIGKILL to process {pid}")
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if process_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
print(f"Sent SIGKILL to {what} pid {pid}")
|
||||
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."""
|
||||
try:
|
||||
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:
|
||||
"""Wait for server to become ready."""
|
||||
start = time.time()
|
||||
last_report = 0.0
|
||||
while time.time() - start < timeout:
|
||||
if is_server_ready(port):
|
||||
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)
|
||||
return False
|
||||
|
||||
@@ -99,68 +186,148 @@ def main() -> int:
|
||||
"--port",
|
||||
type=int,
|
||||
default=8188,
|
||||
help="Server port (default: 8188)"
|
||||
help="Server port (default: 8188)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restart",
|
||||
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(
|
||||
"--wait",
|
||||
action="store_true",
|
||||
help="Wait for server to be ready before exiting"
|
||||
help="Wait for server to be ready before exiting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
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()
|
||||
|
||||
|
||||
# Get project root (parent of .agents directory)
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
skill_dir = os.path.dirname(script_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:
|
||||
print(f"Killing existing server on port {args.port}...")
|
||||
kill_server(args.port)
|
||||
alive_managed = [pid for pid in managed_pids if process_alive(pid)]
|
||||
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)
|
||||
|
||||
# Check if already running
|
||||
if is_server_ready(args.port):
|
||||
print(f"Server already running on port {args.port}")
|
||||
return 0
|
||||
|
||||
# Refuse to kill anything the script did not manage.
|
||||
remaining = find_server_process(args.port)
|
||||
if remaining:
|
||||
print(
|
||||
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
|
||||
print(f"Starting LoRa Manager standalone server on port {args.port}...")
|
||||
cmd = [sys.executable, "standalone.py", "--port", str(args.port)]
|
||||
|
||||
# Start in background
|
||||
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}")
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"standalone.py",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(args.port),
|
||||
]
|
||||
|
||||
if args.detach:
|
||||
# Fully detached launch: new session (setsid), no controlling terminal,
|
||||
# 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
|
||||
if args.wait:
|
||||
print(f"Waiting for server to be ready (timeout: {args.timeout}s)...")
|
||||
if wait_for_server(args.port, args.timeout):
|
||||
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
|
||||
return 0
|
||||
else:
|
||||
print(f"Timeout waiting for server")
|
||||
return 1
|
||||
|
||||
print(f"Timeout waiting for server on port {args.port}")
|
||||
return 1
|
||||
|
||||
print(f"Server starting at http://127.0.0.1:{args.port}/loras")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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 socket
|
||||
import sys
|
||||
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."""
|
||||
try:
|
||||
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:
|
||||
"""Wait for server to become ready."""
|
||||
start = time.time()
|
||||
last_report = 0.0
|
||||
while time.time() - start < timeout:
|
||||
if is_server_ready(port):
|
||||
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)
|
||||
return False
|
||||
|
||||
@@ -36,25 +47,24 @@ def main() -> int:
|
||||
"--port",
|
||||
type=int,
|
||||
default=8188,
|
||||
help="Server port (default: 8188)"
|
||||
help="Server port (default: 8188)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Timeout in seconds (default: 30)"
|
||||
help="Timeout in seconds (default: 30)",
|
||||
)
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
print(f"Waiting for server on port {args.port} (timeout: {args.timeout}s)...")
|
||||
|
||||
|
||||
if wait_for_server(args.port, args.timeout):
|
||||
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
|
||||
return 0
|
||||
else:
|
||||
print(f"Timeout: Server not ready after {args.timeout}s")
|
||||
return 1
|
||||
print(f"Timeout: Server not ready after {args.timeout}s")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"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": {
|
||||
"label": "Ausgeschlossene Modelle verwalten"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
"moveAll": "Alle in Ordner verschieben",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "Inhaltsbewertung festlegen",
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"rematchMetadata": "Mit lokalen Modellen abgleichen",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
"restoreModel": "Modell wiederherstellen",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||
"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...",
|
||||
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||
"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": {
|
||||
"label": "Manage Excluded Models"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"repairMetadata": "Repair Metadata for Selected",
|
||||
"rematchMetadata": "Rematch Selected to Local Models",
|
||||
"reimportMetadata": "Re-import from Source",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
"moveAll": "Move Selected to Folder",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "Set Content Rating",
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"rematchMetadata": "Rematch to local models",
|
||||
"reimportMetadata": "Re-import from Source",
|
||||
"excludeModel": "Exclude Model",
|
||||
"restoreModel": "Restore Model",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||
"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...",
|
||||
"reimportSuccess": "Recipe re-imported successfully",
|
||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"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": {
|
||||
"label": "Gestionar modelos excluidos"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"repairMetadata": "Reparar metadatos de la selección",
|
||||
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
"moveAll": "Mover todos a carpeta",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "Establecer clasificación de contenido",
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"rematchMetadata": "Reasociar con modelos locales",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"excludeModel": "Excluir modelo",
|
||||
"restoreModel": "Restaurar modelo",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||
"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...",
|
||||
"reimportSuccess": "Receta reimportada exitosamente",
|
||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
|
||||
"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": {
|
||||
"label": "Gérer les modèles exclus"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"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",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
"moveAll": "Déplacer tout vers un dossier",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "Définir la classification du contenu",
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"rematchMetadata": "Réassocier aux modèles locaux",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
"restoreModel": "Restaurer le modèle",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"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",
|
||||
"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...",
|
||||
"reimportSuccess": "Recette ré-importée avec succès",
|
||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
||||
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "ניהול מודלים מוחרגים"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-דאטה",
|
||||
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
|
||||
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
"moveAll": "העבר הכל לתיקייה",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "הגדר דירוג תוכן",
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-דאטה",
|
||||
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"excludeModel": "החרג מודל",
|
||||
"restoreModel": "שחזור מודל",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
||||
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "レシピをローカルモデルに再マッチング",
|
||||
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "除外モデルを管理"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
"moveAll": "すべてをフォルダに移動",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "コンテンツレーティングを設定",
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"rematchMetadata": "ローカルモデルに再マッチング",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"excludeModel": "モデルを除外",
|
||||
"restoreModel": "モデルを復元",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||
"reimporting": "ソースからレシピを再インポート中...",
|
||||
"reimportSuccess": "レシピの再インポートが完了しました",
|
||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||
"error": "레시피 재매칭 실패: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "제외된 모델 관리"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
"moveAll": "모두 폴더로 이동",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "콘텐츠 등급 설정",
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"rematchMetadata": "로컬 모델에 다시 매칭",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"excludeModel": "모델 제외",
|
||||
"restoreModel": "모델 복원",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
||||
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "Управление исключёнными моделями"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
"moveAll": "Переместить все в папку",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "Установить рейтинг контента",
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"rematchMetadata": "Сопоставить с локальными моделями",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"excludeModel": "Исключить модель",
|
||||
"restoreModel": "Восстановить модель",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
||||
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||
"reimporting": "Переимпорт рецепта из источника...",
|
||||
"reimportSuccess": "Рецепт успешно переимпортирован",
|
||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "将食谱重新匹配到本地模型",
|
||||
"loading": "正在将食谱重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
|
||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
|
||||
"allFailed": "{failures}/{total} 个食谱重新匹配失败",
|
||||
"noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。",
|
||||
"error": "食谱重新匹配失败:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"repairMetadata": "修复所选中元数据",
|
||||
"rematchMetadata": "将所选中重新匹配到本地模型",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"checkUpdates": "检查所选更新",
|
||||
"moveAll": "移动所选中到文件夹",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "设置内容评级",
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"rematchMetadata": "重新匹配到本地模型",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "恢复模型",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
|
||||
"rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败",
|
||||
"rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
|
||||
"rematchSkipped": "{total} 个所选食谱均无需重新匹配",
|
||||
"rematchFailed": "重新匹配所选食谱失败:{message}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
"reimportSuccess": "配方已从源重新导入成功",
|
||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||
|
||||
@@ -186,6 +186,16 @@
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "將食譜重新匹配到本地模型",
|
||||
"loading": "正在將食譜重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
|
||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
|
||||
"allFailed": "{failures}/{total} 個食譜重新匹配失敗",
|
||||
"noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。",
|
||||
"error": "食譜重新匹配失敗:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
},
|
||||
@@ -768,6 +778,7 @@
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"repairMetadata": "修復所選中元數據",
|
||||
"rematchMetadata": "將所選中重新匹配到本地模型",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
"moveAll": "全部移動到資料夾",
|
||||
@@ -823,6 +834,7 @@
|
||||
"setContentRating": "設定內容分級",
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"rematchMetadata": "重新匹配到本地模型",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"excludeModel": "排除模型",
|
||||
"restoreModel": "還原模型",
|
||||
@@ -1951,6 +1963,12 @@
|
||||
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
|
||||
"rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗",
|
||||
"rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
|
||||
"rematchSkipped": "{total} 個所選食譜均無需重新匹配",
|
||||
"rematchFailed": "重新匹配所選食譜失敗:{message}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||
|
||||
@@ -112,6 +112,11 @@ class RecipeHandlerSet:
|
||||
"repair_recipe": self.management.repair_recipe,
|
||||
"repair_recipes_bulk": self.management.repair_recipes_bulk,
|
||||
"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,
|
||||
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
|
||||
"cancel_batch_import": self.batch_import.cancel_batch_import,
|
||||
@@ -887,6 +892,159 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||
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:
|
||||
"""Delete a recipe and re-import it from its source URL.
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||
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(
|
||||
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
|
||||
|
||||
@@ -24,6 +24,10 @@ from .settings_manager import get_settings_manager
|
||||
|
||||
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
|
||||
|
||||
def _try_certifi_ca_path() -> str | None:
|
||||
"""Return the certifi CA bundle path if available, else None."""
|
||||
try:
|
||||
@@ -85,6 +89,7 @@ class Aria2Downloader:
|
||||
self._rpc_session: Optional[aiohttp.ClientSession] = None
|
||||
self._rpc_session_lock = asyncio.Lock()
|
||||
self._process_lock = asyncio.Lock()
|
||||
self._register_lock = asyncio.Lock()
|
||||
self._transfers: Dict[str, Aria2Transfer] = {}
|
||||
self._poll_interval = 0.5
|
||||
self._state_store = Aria2TransferStateStore()
|
||||
@@ -103,26 +108,58 @@ class Aria2Downloader:
|
||||
progress_callback=None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> 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()
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
if progress_callback is not None:
|
||||
@@ -139,7 +176,9 @@ class Aria2Downloader:
|
||||
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
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(
|
||||
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
|
||||
@@ -242,6 +281,25 @@ class Aria2Downloader:
|
||||
)
|
||||
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]]:
|
||||
"""Return the raw aria2 status payload for a known download."""
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ import os
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
|
||||
from ..config import config
|
||||
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
|
||||
from ..utils.file_utils import calculate_autov3
|
||||
from .recipe_cache import RecipeCache
|
||||
from .recipes.errors import RecipeNotFoundError
|
||||
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
||||
from natsort import natsorted
|
||||
import sys
|
||||
import re
|
||||
@@ -26,6 +28,12 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rematch type-gate alias map: Civitai model types are lowercased before the
|
||||
# VALID_CHECKPOINT_SUB_TYPES membership check, and raw "DiffusionModel" would
|
||||
# lowercase to "diffusionmodel", which is not a valid sub-type. Map it
|
||||
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
||||
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||
|
||||
|
||||
class RecipeScanner:
|
||||
"""Service for scanning and managing recipe images"""
|
||||
@@ -99,6 +107,13 @@ class RecipeScanner:
|
||||
self._local_hash_cache: dict[str, dict[str, Any]] | None = None
|
||||
self._local_hash_cache_versions: tuple[int, int] | None = None
|
||||
self._local_hash_cache_lock = asyncio.Lock()
|
||||
# Computed autov3 map (absent/None-autov3 items only), rebuilt only
|
||||
# when either model scanner's cache_version changes — the
|
||||
# safetensors headers are read once per library scan, not once per
|
||||
# recipe. Mirrors the build_local_hash_cache version pattern.
|
||||
self._rematch_autov3_cache: dict[str, dict[str, Any]] | None = None
|
||||
self._rematch_autov3_versions: tuple[int, int] | None = None
|
||||
self._rematch_autov3_lock = asyncio.Lock()
|
||||
self._initialized = True
|
||||
|
||||
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
|
||||
@@ -145,6 +160,166 @@ class RecipeScanner:
|
||||
self._local_hash_cache_versions = versions
|
||||
return cache
|
||||
|
||||
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
|
||||
"""Return True when a recipe entry is eligible for local re-matching."""
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
unresolved = (
|
||||
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
|
||||
)
|
||||
has_identifier = (
|
||||
entry.get("hash") or entry.get("modelVersionId") or entry.get("id")
|
||||
)
|
||||
return bool(unresolved and has_identifier)
|
||||
|
||||
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
|
||||
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
||||
|
||||
Only absent/``None`` autov3 items are computed; ``''`` is the terminal
|
||||
"checked but unavailable" state and is never recomputed. The dict is
|
||||
reused while both scanners' cache_version values are unchanged, so the
|
||||
safetensors headers are read once per library scan rather than once per
|
||||
recipe. Computed values are lookup keys only — never persisted, never
|
||||
written to items.
|
||||
"""
|
||||
async with self._rematch_autov3_lock:
|
||||
lora_scanner = self._lora_scanner
|
||||
checkpoint_scanner = self._checkpoint_scanner
|
||||
versions = (
|
||||
lora_scanner.cache_version if lora_scanner is not None else 0,
|
||||
checkpoint_scanner.cache_version
|
||||
if checkpoint_scanner is not None
|
||||
else 0,
|
||||
)
|
||||
if (
|
||||
self._rematch_autov3_cache is not None
|
||||
and self._rematch_autov3_versions == versions
|
||||
):
|
||||
return self._rematch_autov3_cache
|
||||
|
||||
cache: dict[str, dict[str, Any]] = {}
|
||||
for scanner in (lora_scanner, checkpoint_scanner):
|
||||
if scanner is None:
|
||||
continue
|
||||
data = await scanner.get_cached_data()
|
||||
for item in data.raw_data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if "autov3" in item and item.get("autov3") is not None:
|
||||
continue
|
||||
file_path = item.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
computed = await asyncio.to_thread(calculate_autov3, file_path)
|
||||
key = (computed or "").lower()
|
||||
if key:
|
||||
cache[key] = item
|
||||
|
||||
self._rematch_autov3_cache = cache
|
||||
self._rematch_autov3_versions = versions
|
||||
return cache
|
||||
|
||||
async def _match_rematch_entry(
|
||||
self,
|
||||
entry: dict[str, Any],
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
*,
|
||||
is_checkpoint: bool,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Match a recipe entry against local models (see
|
||||
``_match_rematch_entry_with_level`` for the level-aware variant).
|
||||
|
||||
Kept as a thin wrapper so callers that only need the matched item
|
||||
(and the direct tests of this method) keep a stable contract.
|
||||
"""
|
||||
item, _level = await self._match_rematch_entry_with_level(
|
||||
entry, local_cache, autov3_cache, is_checkpoint=is_checkpoint
|
||||
)
|
||||
return item
|
||||
|
||||
async def _match_rematch_entry_with_level(
|
||||
self,
|
||||
entry: dict[str, Any],
|
||||
local_cache: dict[str, Any],
|
||||
autov3_cache: dict[str, Any],
|
||||
*,
|
||||
is_checkpoint: bool,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""Match a recipe entry against local models across three levels.
|
||||
|
||||
L1 looks the stored hash up in the type-blind local hash cache; L2
|
||||
falls back to the version index via ``modelVersionId`` or ``id``; L3
|
||||
resolves 12-char hashes through the computed AutoV3 cache. Matched
|
||||
items are type-verified against the entry kind before being returned.
|
||||
|
||||
Returns:
|
||||
Tuple of (matched item, match level) where level is "L1", "L2" or
|
||||
"L3" — or ``(None, None)`` when no usable match exists. A missing
|
||||
local match is an expected outcome (the model may simply not be
|
||||
present locally), not an error.
|
||||
"""
|
||||
entry_hash = (entry.get("hash") or "").lower()
|
||||
|
||||
item = local_cache.get(entry_hash)
|
||||
level = "L1" if item is not None else None
|
||||
|
||||
if item is None:
|
||||
version_id = entry.get("modelVersionId") or entry.get("id")
|
||||
if version_id is not None:
|
||||
if is_checkpoint:
|
||||
item = self._get_checkpoint_from_version_index(str(version_id))
|
||||
else:
|
||||
item = self._get_lora_from_version_index(str(version_id))
|
||||
level = "L2" if item is not None else None
|
||||
|
||||
if item is None and len(entry_hash) == 12:
|
||||
item = autov3_cache.get(entry_hash)
|
||||
level = "L3" if item is not None else None
|
||||
|
||||
if item is None:
|
||||
return (None, None)
|
||||
|
||||
# Type gate: the L1 cache merges lora and checkpoint items and is
|
||||
# type-blind, so a match must be verified against the entry kind.
|
||||
sub_type = (item.get("sub_type") or "").lower()
|
||||
if sub_type:
|
||||
valid = (
|
||||
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
|
||||
)
|
||||
if sub_type not in valid:
|
||||
return (None, None)
|
||||
else:
|
||||
civitai_type = (
|
||||
(item.get("civitai") or {}).get("model", {}) or {}
|
||||
).get("type", "")
|
||||
if civitai_type:
|
||||
normalized = civitai_type.lower()
|
||||
if is_checkpoint:
|
||||
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
|
||||
normalized, normalized
|
||||
)
|
||||
valid = VALID_CHECKPOINT_SUB_TYPES
|
||||
else:
|
||||
valid = VALID_LORA_TYPES
|
||||
if normalized not in valid:
|
||||
return (None, None)
|
||||
|
||||
return (item, level)
|
||||
|
||||
@staticmethod
|
||||
def _entry_identifier(entry: dict[str, Any]) -> str:
|
||||
"""Best-effort human-readable identifier for a recipe entry.
|
||||
|
||||
Used for rematch reports and debug logs; falls back through the keys
|
||||
that carry the most recognisable information first.
|
||||
"""
|
||||
for key in ("modelName", "name", "file_name", "hash", "modelVersionId"):
|
||||
value = entry.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return "unknown"
|
||||
|
||||
def on_library_changed(self) -> None:
|
||||
"""Reset cached state when the active library changes."""
|
||||
|
||||
@@ -411,6 +586,565 @@ class RecipeScanner:
|
||||
|
||||
return False
|
||||
|
||||
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Logs one INFO summary line for this run and delegates the per-recipe
|
||||
work to ``_rematch_recipe_by_id`` (shared with the bulk entry point).
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
||||
Raises RecipeNotFoundError when the recipe is missing.
|
||||
"""
|
||||
result = await self._rematch_recipe_by_id(recipe_id)
|
||||
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
|
||||
logger.info(
|
||||
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
||||
recipe_id,
|
||||
recipe_name,
|
||||
result.get("success"),
|
||||
result.get("matched_entries", 0),
|
||||
result.get("unresolved_entries", 0),
|
||||
result.get("errors", 0),
|
||||
)
|
||||
return result
|
||||
|
||||
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Match snapshots (local hash cache + computed autov3 cache) are built
|
||||
BEFORE acquiring the mutation lock — both are read-only snapshots and
|
||||
the version-cached hash dict would otherwise rebuild mid-run if a scan
|
||||
bumps a scanner's cache_version while we hold the lock.
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
unresolved_entries plus the legacy rematched/skipped/errors
|
||||
fields) and a per-entry ``details`` report. The legacy ``skipped``
|
||||
field means "recipe not updated" and overlaps
|
||||
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
||||
as both). Raises RecipeNotFoundError when the recipe is missing.
|
||||
"""
|
||||
local_cache = await self.build_local_hash_cache()
|
||||
autov3_cache = await self._build_rematch_autov3_cache()
|
||||
|
||||
async with self._mutation_lock:
|
||||
# Get raw recipe from cache directly to avoid formatted fields
|
||||
cache = await self.get_cached_data()
|
||||
recipe = next(
|
||||
(r for r in cache.raw_data if str(r.get("id", "")) == recipe_id), None
|
||||
)
|
||||
|
||||
if not recipe:
|
||||
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||
|
||||
try:
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache
|
||||
)
|
||||
except RecipePersistenceError as exc:
|
||||
logger.error(
|
||||
"Recipe rematch %s (%s) failed to persist: %s",
|
||||
recipe_id,
|
||||
recipe.get("name") or recipe.get("file_path"),
|
||||
exc,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"errors": 1,
|
||||
"rematched": 0,
|
||||
"skipped": 0,
|
||||
"matched_recipes": 0,
|
||||
"matched_entries": 0,
|
||||
"unresolved_recipes": 0,
|
||||
"unresolved_entries": 0,
|
||||
"details": {"matched": [], "unresolved": []},
|
||||
"recipe": recipe,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
unresolved_entries = len(details["unresolved"])
|
||||
unresolved_recipes = 1 if unresolved_entries > 0 else 0
|
||||
|
||||
if rematched == 0:
|
||||
return {
|
||||
"success": True,
|
||||
"rematched": 0,
|
||||
"skipped": 1,
|
||||
"matched_recipes": 0,
|
||||
"matched_entries": 0,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"recipe": recipe,
|
||||
}
|
||||
|
||||
# Enriched re-fetch so the frontend receives file_url/preview fields.
|
||||
return {
|
||||
"success": True,
|
||||
"rematched": rematched,
|
||||
"skipped": 0,
|
||||
"matched_recipes": 1,
|
||||
"matched_entries": rematched,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"recipe": await self.get_recipe_by_id(recipe_id),
|
||||
}
|
||||
|
||||
async def _rematch_single_recipe(
|
||||
self,
|
||||
recipe: Dict[str, Any],
|
||||
local_cache: dict[str, dict[str, Any]],
|
||||
autov3_cache: dict[str, dict[str, Any]],
|
||||
) -> Tuple[int, int, Dict[str, Any]]:
|
||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||
|
||||
Shared per-recipe helper used by ``rematch_recipe_by_id`` and the bulk
|
||||
rematch entry points. Mutates the recipe dict in place, recomputes the
|
||||
fingerprint and persists via ``_save_recipe_persistently`` when any
|
||||
entry changed. ``_schedule_resort`` is deliberately NOT called here —
|
||||
it is hoisted to the public entry points.
|
||||
|
||||
Args:
|
||||
recipe: The recipe dictionary to rematch (modified in-place)
|
||||
local_cache: L1 hash cache snapshot (build_local_hash_cache)
|
||||
autov3_cache: L3 computed-autov3 cache snapshot
|
||||
|
||||
Returns:
|
||||
Tuple of (rematched_entries, errors, details). The errors element
|
||||
is always 0 on a normal return — a persistence failure RAISES
|
||||
``RecipePersistenceError`` so callers can count it. ``details``
|
||||
carries the per-entry outcome:
|
||||
``{"matched": [{type, entry, file_name, match_level}],
|
||||
"unresolved": [{type, entry}]}`` where an unresolved entry is a
|
||||
rematch candidate that found no local match — an expected outcome
|
||||
(the model may simply not exist locally), not an error.
|
||||
|
||||
Raises:
|
||||
RecipePersistenceError: when the recipe changed but
|
||||
``_save_recipe_persistently`` returned False.
|
||||
"""
|
||||
rematched = 0
|
||||
details: Dict[str, Any] = {"matched": [], "unresolved": []}
|
||||
|
||||
# Lora entries
|
||||
loras = recipe.get("loras", [])
|
||||
if isinstance(loras, list):
|
||||
for entry in loras:
|
||||
if not self._is_rematch_candidate(entry):
|
||||
continue
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
entry, local_cache, autov3_cache, is_checkpoint=False
|
||||
)
|
||||
if item is None:
|
||||
details["unresolved"].append(
|
||||
{"type": "lora", "entry": self._entry_identifier(entry)}
|
||||
)
|
||||
continue
|
||||
# Capture the identifier before the write-back mutates the
|
||||
# entry (file_name/isDeleted are rewritten in place).
|
||||
details["matched"].append(
|
||||
{
|
||||
"type": "lora",
|
||||
"entry": self._entry_identifier(entry),
|
||||
"file_name": item.get("file_name") or "",
|
||||
"match_level": level,
|
||||
}
|
||||
)
|
||||
self._write_rematch_lora_entry(entry, item)
|
||||
rematched += 1
|
||||
|
||||
# Checkpoint entry (dict only — legacy string checkpoints are skipped
|
||||
# silently since ``entry.get`` on a str would raise AttributeError).
|
||||
checkpoint = recipe.get("checkpoint")
|
||||
if isinstance(checkpoint, dict):
|
||||
if self._is_rematch_candidate(checkpoint):
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
checkpoint, local_cache, autov3_cache, is_checkpoint=True
|
||||
)
|
||||
if item is None:
|
||||
details["unresolved"].append(
|
||||
{
|
||||
"type": "checkpoint",
|
||||
"entry": self._entry_identifier(checkpoint),
|
||||
}
|
||||
)
|
||||
else:
|
||||
details["matched"].append(
|
||||
{
|
||||
"type": "checkpoint",
|
||||
"entry": self._entry_identifier(checkpoint),
|
||||
"file_name": item.get("file_name") or "",
|
||||
"match_level": level,
|
||||
}
|
||||
)
|
||||
self._write_rematch_checkpoint_entry(checkpoint, item)
|
||||
rematched += 1
|
||||
|
||||
# Per-recipe detail is DEBUG only: one INFO line per recipe would
|
||||
# flood the log for large libraries, and unresolved entries are a
|
||||
# normal outcome rather than something to warn about.
|
||||
if details["matched"] or details["unresolved"]:
|
||||
matched_desc = ", ".join(
|
||||
f"{m['entry']} -> {m['file_name']} ({m['match_level']})"
|
||||
for m in details["matched"]
|
||||
) or "-"
|
||||
unresolved_desc = ", ".join(
|
||||
u["entry"] for u in details["unresolved"]
|
||||
) or "-"
|
||||
logger.debug(
|
||||
"Recipe rematch %s: matched %d entries [%s]; unresolved %d [%s]",
|
||||
recipe.get("id") or recipe.get("file_path"),
|
||||
len(details["matched"]),
|
||||
matched_desc,
|
||||
len(details["unresolved"]),
|
||||
unresolved_desc,
|
||||
)
|
||||
|
||||
if rematched == 0:
|
||||
return (0, 0, details)
|
||||
|
||||
from ..utils.utils import calculate_recipe_fingerprint
|
||||
|
||||
recipe["fingerprint"] = calculate_recipe_fingerprint(recipe.get("loras", []))
|
||||
|
||||
saved = await self._save_recipe_persistently(recipe)
|
||||
if not saved:
|
||||
raise RecipePersistenceError(
|
||||
f"Failed to persist recipe {recipe.get('id')} after rematch"
|
||||
)
|
||||
|
||||
self._update_fts_index_for_recipe(recipe, "update")
|
||||
return (rematched, 0, details)
|
||||
|
||||
async def rematch_all_recipes(
|
||||
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Match snapshots (local hash cache + computed autov3 cache) are built
|
||||
ONCE before the loop — both are read-only and the version-cached hash
|
||||
dict would otherwise rebuild mid-run if a scan bumps a scanner's
|
||||
cache_version while the mutation lock is held. ``_schedule_resort`` is
|
||||
called exactly once after the loop: it spawns an asyncio task per call,
|
||||
so per-recipe calls would race one resort task per recipe.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
(started/processing/cancelled/completed events).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch run with unified counters
|
||||
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
||||
entries plus the legacy success/status/rematched/skipped/errors/
|
||||
total fields). ``rematched`` (legacy) counts updated recipes —
|
||||
use ``matched_entries`` for the entry-level total.
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback({"status": "started"})
|
||||
|
||||
# Match snapshots built once and shared by every recipe in the loop.
|
||||
local_cache = await self.build_local_hash_cache()
|
||||
autov3_cache = await self._build_rematch_autov3_cache()
|
||||
|
||||
async with self._mutation_lock:
|
||||
cache = await self.get_cached_data()
|
||||
all_recipes = list(cache.raw_data)
|
||||
total = len(all_recipes)
|
||||
matched_recipes = 0
|
||||
matched_entries = 0
|
||||
unresolved_recipes = 0
|
||||
unresolved_entries = 0
|
||||
skipped_count = 0
|
||||
errors_count = 0
|
||||
|
||||
for i, recipe in enumerate(all_recipes):
|
||||
if self.is_cancelled():
|
||||
logger.info(
|
||||
"Recipe rematch cancelled by user after %d/%d recipes: "
|
||||
"%d updated (%d entries matched), %d unresolved entries "
|
||||
"in %d recipes, %d errors",
|
||||
i,
|
||||
total,
|
||||
matched_recipes,
|
||||
matched_entries,
|
||||
unresolved_entries,
|
||||
unresolved_recipes,
|
||||
errors_count,
|
||||
)
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
{
|
||||
"status": "cancelled",
|
||||
"current": i,
|
||||
"total": total,
|
||||
"rematched": matched_recipes,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
"matched_recipes": matched_recipes,
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "cancelled",
|
||||
"rematched": matched_recipes,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
"total": total,
|
||||
"matched_recipes": matched_recipes,
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
}
|
||||
|
||||
try:
|
||||
# Report progress
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
{
|
||||
"status": "processing",
|
||||
"current": i + 1,
|
||||
"total": total,
|
||||
"recipe_name": recipe.get("name", "Unknown"),
|
||||
}
|
||||
)
|
||||
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache
|
||||
)
|
||||
if rematched > 0:
|
||||
matched_recipes += 1
|
||||
matched_entries += rematched
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
recipe_unresolved = len(details["unresolved"])
|
||||
if recipe_unresolved > 0:
|
||||
unresolved_recipes += 1
|
||||
unresolved_entries += recipe_unresolved
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"Error rematching recipe {recipe.get('file_path')}: {exc}"
|
||||
)
|
||||
errors_count += 1
|
||||
|
||||
# Hoisted to one call — _schedule_resort spawns an asyncio task
|
||||
# per call, so per-recipe calls would race 5k resort tasks.
|
||||
self._schedule_resort()
|
||||
|
||||
logger.info(
|
||||
"Recipe rematch complete: %d/%d recipes updated (%d entries "
|
||||
"matched), %d unresolved entries in %d recipes, %d skipped, "
|
||||
"%d errors in %.2fs",
|
||||
matched_recipes,
|
||||
total,
|
||||
matched_entries,
|
||||
unresolved_entries,
|
||||
unresolved_recipes,
|
||||
skipped_count,
|
||||
errors_count,
|
||||
time.perf_counter() - start_time,
|
||||
)
|
||||
|
||||
# Final progress update
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
{
|
||||
"status": "completed",
|
||||
"rematched": matched_recipes,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
"total": total,
|
||||
"matched_recipes": matched_recipes,
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"rematched": matched_recipes,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
"total": total,
|
||||
"matched_recipes": matched_recipes,
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
}
|
||||
|
||||
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
||||
"""Rematch a set of recipes by their IDs.
|
||||
|
||||
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
||||
counted as skipped, and unexpected per-recipe exceptions are counted as
|
||||
errors with the loop continuing so partial results are never lost.
|
||||
Persist failures are already converted to the by_id return shape and
|
||||
are counted via its ``errors`` field only — never double-counted here.
|
||||
|
||||
Args:
|
||||
recipe_ids: List of recipe ids to rematch.
|
||||
|
||||
Returns:
|
||||
Dict summary of the bulk run with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
unresolved_entries plus the legacy total/rematched/skipped/errors
|
||||
fields) and a per-recipe ``details`` list. The legacy ``rematched``
|
||||
field is the total entry count (same as ``matched_entries``) —
|
||||
unlike ``rematch_all_recipes`` where it counts updated recipes.
|
||||
"""
|
||||
total = len(recipe_ids)
|
||||
matched_recipes = 0
|
||||
matched_entries = 0
|
||||
unresolved_recipes = 0
|
||||
unresolved_entries = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
recipes: List[Dict[str, Any]] = []
|
||||
details_list: List[Dict[str, Any]] = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
try:
|
||||
result = await self._rematch_recipe_by_id(recipe_id)
|
||||
if result.get("success"):
|
||||
matched_recipes += result.get("matched_recipes", 0)
|
||||
matched_entries += result.get("matched_entries", 0)
|
||||
unresolved_recipes += result.get("unresolved_recipes", 0)
|
||||
unresolved_entries += result.get("unresolved_entries", 0)
|
||||
skipped += result.get("skipped", 0)
|
||||
if result.get("recipe"):
|
||||
recipes.append(result["recipe"])
|
||||
if result.get("details"):
|
||||
details_list.append(
|
||||
{"recipe_id": recipe_id, **result["details"]}
|
||||
)
|
||||
else:
|
||||
errors += result.get("errors", 0)
|
||||
except RecipeNotFoundError:
|
||||
skipped += 1
|
||||
except Exception as exc:
|
||||
logger.error(f"Error rematching recipe {recipe_id}: {exc}")
|
||||
errors += 1
|
||||
|
||||
self._schedule_resort()
|
||||
|
||||
logger.info(
|
||||
"Recipe bulk rematch: %d/%d recipes updated (%d entries matched), "
|
||||
"%d unresolved entries in %d recipes, %d skipped, %d errors",
|
||||
matched_recipes,
|
||||
total,
|
||||
matched_entries,
|
||||
unresolved_entries,
|
||||
unresolved_recipes,
|
||||
skipped,
|
||||
errors,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total": total,
|
||||
"rematched": matched_entries,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"matched_recipes": matched_recipes,
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"recipes": recipes,
|
||||
"details": details_list,
|
||||
}
|
||||
|
||||
def _write_rematch_lora_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Write back a matched local model to a lora recipe entry."""
|
||||
entry["isDeleted"] = False
|
||||
|
||||
# Only truthy hashes are written — pending/failed items carry an empty
|
||||
# sha256 and an unconditional write would wipe a valid stored hash.
|
||||
new_hash = (item.get("sha256") or "").lower()
|
||||
if new_hash:
|
||||
entry["hash"] = new_hash
|
||||
|
||||
if item.get("file_name"):
|
||||
entry["file_name"] = item["file_name"]
|
||||
|
||||
civitai = item.get("civitai")
|
||||
if isinstance(civitai, dict):
|
||||
if civitai.get("id") is not None:
|
||||
entry["modelVersionId"] = civitai["id"]
|
||||
# modelName comes from the item, NOT civitai.model.name — the slim
|
||||
# civitai payload drops model.name entirely.
|
||||
if item.get("model_name"):
|
||||
entry["modelName"] = item["model_name"]
|
||||
if civitai.get("name"):
|
||||
entry["modelVersionName"] = civitai["name"]
|
||||
|
||||
def _write_rematch_checkpoint_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Write back a matched local model to a checkpoint recipe entry.
|
||||
|
||||
Follows the pinned stored key set: parser-style entries carry
|
||||
name/version/id/type/baseModel/file_name/hash; widget-style entries
|
||||
additionally carry modelName/modelVersionName. Keys are only updated
|
||||
when they already exist on the entry (or written fresh for the
|
||||
identifier key when neither identifier form exists).
|
||||
"""
|
||||
entry["isDeleted"] = False
|
||||
|
||||
new_hash = (item.get("sha256") or "").lower()
|
||||
if new_hash:
|
||||
entry["hash"] = new_hash
|
||||
|
||||
if item.get("file_name"):
|
||||
entry["file_name"] = item["file_name"]
|
||||
|
||||
civitai = item.get("civitai")
|
||||
civ_name = civitai.get("name") if isinstance(civitai, dict) else None
|
||||
civ_id = civitai.get("id") if isinstance(civitai, dict) else None
|
||||
item_name = item.get("model_name")
|
||||
item_base_model = item.get("base_model")
|
||||
|
||||
# Backfill name/version/baseModel only when the entry already has them.
|
||||
if "name" in entry and item_name:
|
||||
entry["name"] = item_name
|
||||
if "version" in entry and civ_name:
|
||||
entry["version"] = civ_name
|
||||
if "baseModel" in entry and item_base_model:
|
||||
entry["baseModel"] = item_base_model
|
||||
|
||||
# Widget-style entries (modelName/modelVersionName) get stale values
|
||||
# refreshed; parser-style entries never gain them.
|
||||
if "modelName" in entry and item_name:
|
||||
entry["modelName"] = item_name
|
||||
if "modelVersionName" in entry and civ_name:
|
||||
entry["modelVersionName"] = civ_name
|
||||
|
||||
# Identifier key updated per the entry's existing convention.
|
||||
if civ_id is not None:
|
||||
if "modelVersionId" in entry:
|
||||
entry["modelVersionId"] = civ_id
|
||||
elif "id" in entry:
|
||||
entry["id"] = civ_id
|
||||
else:
|
||||
entry["modelVersionId"] = civ_id
|
||||
|
||||
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||
recipe_id = recipe.get("id")
|
||||
|
||||
@@ -20,3 +20,12 @@ class RecipeDownloadError(RecipeServiceError):
|
||||
|
||||
class RecipeConflictError(RecipeServiceError):
|
||||
"""Raised when a conflicting recipe state is detected."""
|
||||
|
||||
|
||||
class RecipePersistenceError(RecipeServiceError):
|
||||
"""Raised when a rematched recipe cannot be persisted to disk.
|
||||
|
||||
Raised by the recipe rematch path when ``_save_recipe_persistently``
|
||||
returns False (JSON/EXIF/SQLite write failure). Callers translate it
|
||||
into a ``success: False`` summary with an ``error`` message key.
|
||||
"""
|
||||
|
||||
@@ -22,6 +22,8 @@ class WebSocketManager:
|
||||
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
||||
# Add recipe repair progress tracking
|
||||
self._recipe_repair_progress: Optional[Dict[str, Any]] = None
|
||||
# Add recipe rematch progress tracking
|
||||
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
||||
self._auto_organize_lock = asyncio.Lock()
|
||||
|
||||
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||
@@ -223,6 +225,30 @@ class WebSocketManager:
|
||||
status = self._recipe_repair_progress.get('status')
|
||||
return status in ['started', 'processing']
|
||||
|
||||
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
||||
"""Broadcast recipe rematch progress to connected clients"""
|
||||
# Store progress data in memory
|
||||
self._recipe_rematch_progress = data
|
||||
|
||||
# Broadcast via WebSocket
|
||||
await self.broadcast(data)
|
||||
|
||||
def get_recipe_rematch_progress(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get current recipe rematch progress"""
|
||||
return self._recipe_rematch_progress
|
||||
|
||||
def cleanup_recipe_rematch_progress(self):
|
||||
"""Clear recipe rematch progress data if it is in a finished state"""
|
||||
if self._recipe_rematch_progress and self._recipe_rematch_progress.get('status') in ['completed', 'cancelled', 'error']:
|
||||
self._recipe_rematch_progress = None
|
||||
|
||||
def is_recipe_rematch_running(self) -> bool:
|
||||
"""Check if recipe rematch is currently running"""
|
||||
if not self._recipe_rematch_progress:
|
||||
return False
|
||||
status = self._recipe_rematch_progress.get('status')
|
||||
return status in ['started', 'processing']
|
||||
|
||||
def is_auto_organize_running(self) -> bool:
|
||||
"""Check if auto-organize is currently running"""
|
||||
if not self._auto_organize_progress:
|
||||
|
||||
@@ -16,6 +16,8 @@ const RECIPE_ENDPOINTS = {
|
||||
moveBulk: '/api/lm/recipes/move-bulk',
|
||||
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||
repairBulk: '/api/lm/recipes/repair-bulk',
|
||||
rematchBulk: '/api/lm/recipes/rematch-bulk',
|
||||
rematchSingle: '/api/lm/recipe/{recipe_id}/rematch',
|
||||
};
|
||||
|
||||
const RECIPE_SIDEBAR_CONFIG = {
|
||||
@@ -586,6 +588,38 @@ export class RecipeSidebarApiClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
async rematchBulkModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
}
|
||||
|
||||
const recipeIds = filePaths
|
||||
.map((path) => extractRecipeId(path))
|
||||
.filter((id) => !!id);
|
||||
|
||||
if (recipeIds.length === 0) {
|
||||
throw new Error('No recipe IDs could be derived from file paths');
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to rematch recipes');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async bulkDeleteModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
|
||||
@@ -43,6 +43,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
|
||||
const reimportMetadataItem = this.menu.querySelector('[data-action="reimport-metadata"]');
|
||||
const rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]');
|
||||
|
||||
if (repairMetadataItem) {
|
||||
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
|
||||
@@ -50,6 +51,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (reimportMetadataItem) {
|
||||
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
|
||||
}
|
||||
if (rematchMetadataItem) {
|
||||
rematchMetadataItem.style.display = config.rematchMetadata ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const isEmbeddings = currentModelType === 'embeddings';
|
||||
if (sendToWorkflowAppendItem) {
|
||||
@@ -282,6 +286,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'repair-metadata':
|
||||
bulkManager.repairSelectedRecipes();
|
||||
break;
|
||||
case 'rematch-metadata':
|
||||
bulkManager.rematchSelectedRecipes();
|
||||
break;
|
||||
case 'reimport-metadata':
|
||||
bulkManager.reimportSelectedRecipes();
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]');
|
||||
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
||||
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
||||
|
||||
@@ -41,6 +42,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
groupByModelItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
rematchRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
licenseRefreshItem?.classList.remove('hidden');
|
||||
@@ -49,11 +51,28 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
groupByModelItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
rematchRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
this._updateSeparatorVisibility();
|
||||
|
||||
super.showMenu(x, y, contextOrigin);
|
||||
}
|
||||
|
||||
_updateSeparatorVisibility() {
|
||||
const children = Array.from(this.menu.children);
|
||||
const isVisible = (el) => el.classList.contains('context-menu-item') && !el.classList.contains('hidden');
|
||||
|
||||
children.forEach((el, index) => {
|
||||
if (!el.classList.contains('context-menu-separator')) {
|
||||
return;
|
||||
}
|
||||
const hasVisibleBefore = children.slice(0, index).some(isVisible);
|
||||
const hasVisibleAfter = children.slice(index + 1).some(isVisible);
|
||||
el.classList.toggle('hidden', !(hasVisibleBefore && hasVisibleAfter));
|
||||
});
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
switch (action) {
|
||||
case 'cleanup-example-images-folders':
|
||||
@@ -81,6 +100,11 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to repair recipes:', error);
|
||||
});
|
||||
break;
|
||||
case 'rematch-recipes':
|
||||
this.rematchRecipes(menuItem).catch((error) => {
|
||||
console.error('Failed to rematch recipes:', error);
|
||||
});
|
||||
break;
|
||||
case 'manage-excluded-models':
|
||||
this.manageExcludedModels();
|
||||
break;
|
||||
@@ -439,4 +463,143 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to cancel recipe repair:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async rematchRecipes(menuItem) {
|
||||
if (this._rematchInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._rematchInProgress = true;
|
||||
menuItem?.classList.add('disabled');
|
||||
|
||||
const loadingMessage = translate(
|
||||
'globalContextMenu.rematchRecipes.loading',
|
||||
{},
|
||||
'Rematching recipes to local models...'
|
||||
);
|
||||
|
||||
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
|
||||
progressUI?.showCancelButton(() => this.cancelRematch());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to start rematch');
|
||||
}
|
||||
|
||||
// Poll for progress (mirrors the repair flow; the backend reports `rematched` counts)
|
||||
let isComplete = false;
|
||||
while (!isComplete && this._rematchInProgress) {
|
||||
const progressResponse = await fetch('/api/lm/recipes/rematch-progress');
|
||||
if (progressResponse.ok) {
|
||||
const progressResult = await progressResponse.json();
|
||||
if (progressResult.success && progressResult.progress) {
|
||||
const p = progressResult.progress;
|
||||
if (p.status === 'processing') {
|
||||
const percent = (p.current / p.total) * 100;
|
||||
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
|
||||
} else if (p.status === 'completed') {
|
||||
isComplete = true;
|
||||
// Newer backends report unified matched_entries /
|
||||
// matched_recipes; fall back to legacy `rematched`
|
||||
// (recipe count) for older ones.
|
||||
const entries = p.matched_entries ?? p.rematched ?? 0;
|
||||
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||
const failures = p.errors || 0;
|
||||
const unresolved = p.unresolved_entries ?? 0;
|
||||
if (entries > 0) {
|
||||
const successKey = failures > 0
|
||||
? 'globalContextMenu.rematchRecipes.successErrors'
|
||||
: 'globalContextMenu.rematchRecipes.success';
|
||||
const successText = failures > 0
|
||||
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
||||
: `Matched ${entries} entries across ${recipes} recipes.`;
|
||||
progressUI?.complete(translate(
|
||||
successKey,
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
successText
|
||||
));
|
||||
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
|
||||
} else if (failures > 0) {
|
||||
// Nothing matched and at least one recipe
|
||||
// errored — "no rematch needed" would be
|
||||
// actively misleading here.
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.allFailed',
|
||||
{ total: p.total, recipes, entries, failures },
|
||||
`Rematch failed for ${failures} of ${p.total} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
|
||||
} else if (unresolved > 0) {
|
||||
// Entries existed but have no local model —
|
||||
// expected for models deleted from Civitai;
|
||||
// informational, not an error.
|
||||
const unresolvedRecipes = p.unresolved_recipes ?? 0;
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.noMatch',
|
||||
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
|
||||
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
|
||||
} else {
|
||||
// Everything was skipped (nothing to do).
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.success',
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
`Matched ${entries} entries across ${recipes} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
||||
}
|
||||
// Refresh recipes page if active
|
||||
if (window.recipesPage) {
|
||||
window.recipesPage.refresh();
|
||||
}
|
||||
} else if (p.status === 'error') {
|
||||
throw new Error(p.error || 'Rematch failed');
|
||||
} else if (p.status === 'cancelled') {
|
||||
isComplete = true;
|
||||
const cancelledEntries = p.matched_entries ?? p.rematched ?? 0;
|
||||
const cancelledRecipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.cancelled',
|
||||
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
||||
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
|
||||
}
|
||||
} else if (progressResponse.status === 404) {
|
||||
// Progress might have finished quickly and been cleaned up
|
||||
isComplete = true;
|
||||
progressUI?.complete();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recipe rematch failed:', error);
|
||||
progressUI?.complete(translate('globalContextMenu.rematchRecipes.error', { message: error.message }, 'Rematch failed: {message}'));
|
||||
showToast('globalContextMenu.rematchRecipes.error', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this._rematchInProgress = false;
|
||||
menuItem?.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async cancelRematch() {
|
||||
try {
|
||||
await fetch('/api/lm/recipes/cancel-rematch', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel recipe rematch:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Repair recipe metadata
|
||||
this.repairRecipe(recipeId);
|
||||
break;
|
||||
case 'rematch':
|
||||
// Rematch recipe resources to local models
|
||||
this.rematchRecipe(recipeId);
|
||||
break;
|
||||
case 'reimport':
|
||||
this.reimportRecipe(recipeId);
|
||||
break;
|
||||
@@ -330,6 +334,62 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
async rematchRecipe(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture before any await: the menu's click handler nulls currentCard
|
||||
const filePath = this.currentCard?.dataset?.filepath;
|
||||
|
||||
try {
|
||||
showToast('Rematching recipe to local models...', {}, 'info');
|
||||
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const matchedEntries = result.matched_entries || result.rematched || 0;
|
||||
const failures = result.errors || 0;
|
||||
if (matchedEntries > 0) {
|
||||
const toastKey = failures > 0
|
||||
? 'toast.recipes.rematchCompleteErrors'
|
||||
: 'toast.recipes.rematchComplete';
|
||||
showToast(
|
||||
toastKey,
|
||||
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
|
||||
failures > 0 ? 'warning' : 'success'
|
||||
);
|
||||
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||
if (detailResponse.ok) {
|
||||
const updatedRecipe = await detailResponse.json();
|
||||
if (filePath && state.virtualScroller) {
|
||||
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||
}
|
||||
}
|
||||
} else if (result.unresolved_entries > 0) {
|
||||
// Entries existed but have no local model — expected for
|
||||
// models deleted from Civitai; informational, not an error.
|
||||
showToast(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Rematch failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rematching recipe:', error);
|
||||
showToast('toast.recipes.rematchFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async reimportRecipe(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('recipes.contextMenu.reimport.missingId', {}, 'error');
|
||||
|
||||
@@ -95,7 +95,8 @@ export class BulkManager {
|
||||
setFavorite: true,
|
||||
unfavorite: true,
|
||||
repairMetadata: true,
|
||||
reimportMetadata: true
|
||||
reimportMetadata: true,
|
||||
rematchMetadata: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -871,6 +872,105 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
async rematchSelectedRecipes() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.currentPageType !== 'recipes') {
|
||||
showToast('This operation is only available for recipes', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
|
||||
if (typeof apiClient.rematchBulkModels !== 'function') {
|
||||
showToast('Bulk rematch is not supported for this model type', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
|
||||
|
||||
const result = await apiClient.rematchBulkModels(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
const total = result.total || filePaths.length;
|
||||
// Unified counters from the backend; legacy fields fall back
|
||||
// for older backends: `rematched` (entry count) for
|
||||
// matched_entries, `total` (selection size) for
|
||||
// matched_recipes.
|
||||
const rematched = result.rematched || 0;
|
||||
const skipped = result.skipped || 0;
|
||||
const matchedRecipes = result.matched_recipes || result.total || 0;
|
||||
const matchedEntries = result.matched_entries || rematched;
|
||||
const failures = result.errors || 0;
|
||||
const unresolvedEntries = result.unresolved_entries || 0;
|
||||
const unresolvedRecipes = result.unresolved_recipes || 0;
|
||||
|
||||
const recipes = result.recipes || [];
|
||||
for (const recipe of recipes) {
|
||||
if (recipe.file_path) {
|
||||
state.virtualScroller.updateSingleItem(
|
||||
recipe.file_path,
|
||||
recipe
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedEntries > 0) {
|
||||
const hasFailures = failures > 0;
|
||||
const toastKey = hasFailures
|
||||
? 'toast.recipes.rematchCompleteErrors'
|
||||
: 'toast.recipes.rematchComplete';
|
||||
showToast(
|
||||
toastKey,
|
||||
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
|
||||
hasFailures ? 'warning' : 'success'
|
||||
);
|
||||
} else if (failures > 0) {
|
||||
// Nothing matched and at least one recipe errored —
|
||||
// "no rematch needed" would be actively misleading here.
|
||||
showToast(
|
||||
'toast.recipes.rematchAllFailed',
|
||||
{ total, failures },
|
||||
'error'
|
||||
);
|
||||
} else if (unresolvedEntries > 0) {
|
||||
// Entries existed but have no local model — expected for
|
||||
// models deleted from Civitai; informational, not an error.
|
||||
showToast(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
'toast.recipes.rematchSkipped',
|
||||
{ total },
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
if (state.bulkMode) this.toggleBulkMode();
|
||||
} else {
|
||||
throw new Error(result.error || 'Bulk rematch failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during bulk recipe rematch:', error);
|
||||
showToast('toast.recipes.rematchFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
if (state.loadingManager?.hide) {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllMetadata() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
|
||||
@@ -95,7 +95,10 @@
|
||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="repair-metadata">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span>
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span> (Deprecated)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="rematch-metadata">
|
||||
<i class="fas fa-link"></i> <span>{{ t('loras.bulkOperations.rematchMetadata') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="reimport-metadata">
|
||||
<i class="fas fa-undo-alt"></i> <span>{{ t('loras.bulkOperations.reimportMetadata') }}</span>
|
||||
@@ -200,7 +203,10 @@
|
||||
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="repair-recipes">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span> (Deprecated)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="rematch-recipes">
|
||||
<i class="fas fa-link"></i> <span>{{ t('globalContextMenu.rematchRecipes.label') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
||||
<!-- Metadata -->
|
||||
<div class="context-menu-item" data-action="repair">
|
||||
<i class="fas fa-tools"></i> {{ t('loras.contextMenu.repairMetadata') }}
|
||||
<i class="fas fa-tools"></i> {{ t('loras.contextMenu.repairMetadata') }} (Deprecated)
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="rematch">
|
||||
<i class="fas fa-link"></i> {{ t('loras.contextMenu.rematchMetadata') }}
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="reimport">
|
||||
<i class="fas fa-undo-alt"></i> {{ t('loras.contextMenu.reimportMetadata') }}
|
||||
|
||||
@@ -216,4 +216,66 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('posts exactly recipe_ids when rematching in bulk', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
total: 2,
|
||||
rematched: 2,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
recipes: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await api.rematchBulkModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipes/rematch-bulk',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
// Exact-body assertion: no extra fields beyond recipe_ids
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||
recipe_ids: ['a', 'b'],
|
||||
});
|
||||
expect(result).toMatchObject({ success: true, rematched: 2 });
|
||||
});
|
||||
|
||||
it('derives recipe IDs via extractRecipeId and skips empty paths', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, total: 1, rematched: 0, skipped: 1, errors: 0 }),
|
||||
});
|
||||
|
||||
await api.rematchBulkModels(['', '/recipes/sub folder/recipe-1.webp']);
|
||||
|
||||
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||
recipe_ids: ['recipe-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bulk rematch without file paths', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
|
||||
await expect(api.rematchBulkModels([])).rejects.toThrow('No file paths provided');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws the backend error when bulk rematch fails', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ success: false, error: 'Rematch already running' }),
|
||||
});
|
||||
|
||||
await expect(api.rematchBulkModels(['/recipes/a.webp'])).rejects.toThrow('Rematch already running');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2186,4 +2186,286 @@ describe('Interaction-level regression coverage', () => {
|
||||
document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
|
||||
});
|
||||
|
||||
it('runs global recipe rematch with polling and toasts the rematched count', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
|
||||
const menu = new GlobalContextMenu();
|
||||
const rematchItem = document.querySelector('[data-action="rematch-recipes"]');
|
||||
|
||||
const progressUI = {
|
||||
updateProgress: vi.fn(),
|
||||
showCancelButton: vi.fn(),
|
||||
complete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI);
|
||||
window.recipesPage = { refresh: vi.fn() };
|
||||
|
||||
// Menu item is recipes-page only
|
||||
stateStub.currentPageType = 'recipes';
|
||||
menu.showMenu(100, 200);
|
||||
expect(rematchItem.classList.contains('hidden')).toBe(false);
|
||||
stateStub.currentPageType = 'loras';
|
||||
menu.showMenu(100, 200);
|
||||
expect(rematchItem.classList.contains('hidden')).toBe(true);
|
||||
stateStub.currentPageType = 'recipes';
|
||||
menu.showMenu(100, 200);
|
||||
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
progress: { status: 'completed', rematched: 2, skipped: 1, errors: 0, total: 3, matched_recipes: 2, matched_entries: 5, unresolved_recipes: 1, unresolved_entries: 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
expect(rematchItem.classList.contains('disabled')).toBe(true);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes.');
|
||||
// Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.success',
|
||||
{ count: 2, recipes: 2, entries: 5, failures: 0 },
|
||||
'success'
|
||||
);
|
||||
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(rematchItem.classList.contains('disabled')).toBe(false);
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete window.recipesPage;
|
||||
delete stateStub.currentPageType;
|
||||
});
|
||||
|
||||
it('uses the warning toast variant when a global rematch completes with failures', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
|
||||
const menu = new GlobalContextMenu();
|
||||
const rematchItem = document.querySelector('[data-action="rematch-recipes"]');
|
||||
|
||||
const progressUI = {
|
||||
updateProgress: vi.fn(),
|
||||
showCancelButton: vi.fn(),
|
||||
complete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI);
|
||||
window.recipesPage = { refresh: vi.fn() };
|
||||
|
||||
stateStub.currentPageType = 'recipes';
|
||||
menu.showMenu(100, 200);
|
||||
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
progress: { status: 'completed', rematched: 2, skipped: 0, errors: 2, total: 3, matched_recipes: 2, matched_entries: 5, unresolved_recipes: 0, unresolved_entries: 0 },
|
||||
}),
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.successErrors',
|
||||
{ count: 2, recipes: 2, entries: 5, failures: 2 },
|
||||
'warning'
|
||||
);
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete window.recipesPage;
|
||||
delete stateStub.currentPageType;
|
||||
});
|
||||
|
||||
it('toasts an error when every recipe in a global rematch failed', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
|
||||
const menu = new GlobalContextMenu();
|
||||
const rematchItem = document.querySelector('[data-action="rematch-recipes"]');
|
||||
|
||||
const progressUI = {
|
||||
updateProgress: vi.fn(),
|
||||
showCancelButton: vi.fn(),
|
||||
complete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI);
|
||||
window.recipesPage = { refresh: vi.fn() };
|
||||
|
||||
stateStub.currentPageType = 'recipes';
|
||||
menu.showMenu(100, 200);
|
||||
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
progress: { status: 'completed', rematched: 0, skipped: 0, errors: 3, total: 3, matched_recipes: 0, matched_entries: 0, unresolved_recipes: 0, unresolved_entries: 0 },
|
||||
}),
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.allFailed',
|
||||
{ total: 3, recipes: 0, entries: 0, failures: 3 },
|
||||
'error'
|
||||
);
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete window.recipesPage;
|
||||
delete stateStub.currentPageType;
|
||||
});
|
||||
|
||||
it('toasts an info message when a global rematch found no local matches', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
|
||||
const menu = new GlobalContextMenu();
|
||||
const rematchItem = document.querySelector('[data-action="rematch-recipes"]');
|
||||
|
||||
const progressUI = {
|
||||
updateProgress: vi.fn(),
|
||||
showCancelButton: vi.fn(),
|
||||
complete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI);
|
||||
window.recipesPage = { refresh: vi.fn() };
|
||||
|
||||
stateStub.currentPageType = 'recipes';
|
||||
menu.showMenu(100, 200);
|
||||
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
progress: { status: 'completed', rematched: 0, skipped: 2, errors: 0, total: 3, matched_recipes: 0, matched_entries: 0, unresolved_recipes: 1, unresolved_entries: 2 },
|
||||
}),
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.noMatch',
|
||||
{ entries: 2, recipes: 1, total: 3, failures: 0 },
|
||||
'info'
|
||||
);
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete window.recipesPage;
|
||||
delete stateStub.currentPageType;
|
||||
});
|
||||
|
||||
it('toasts the rematched count when a global rematch is cancelled', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="globalContextMenu" class="context-menu">
|
||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { GlobalContextMenu } = await import('../../../static/js/components/ContextMenu/GlobalContextMenu.js');
|
||||
const menu = new GlobalContextMenu();
|
||||
const rematchItem = document.querySelector('[data-action="rematch-recipes"]');
|
||||
|
||||
const progressUI = {
|
||||
updateProgress: vi.fn(),
|
||||
showCancelButton: vi.fn(),
|
||||
complete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
loadingManagerStub.showEnhancedProgress = vi.fn(() => progressUI);
|
||||
|
||||
stateStub.currentPageType = 'recipes';
|
||||
menu.showMenu(100, 200);
|
||||
|
||||
global.fetch = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
progress: { status: 'cancelled', rematched: 1, skipped: 0, errors: 0, total: 3, matched_recipes: 1, matched_entries: 2, unresolved_recipes: 0, unresolved_entries: 0 },
|
||||
}),
|
||||
});
|
||||
|
||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await flushAsyncTasks();
|
||||
}
|
||||
|
||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'globalContextMenu.rematchRecipes.cancelled',
|
||||
{ count: 1, recipes: 1, entries: 2 },
|
||||
'info'
|
||||
);
|
||||
expect(menu._rematchInProgress).toBe(false);
|
||||
|
||||
delete stateStub.currentPageType;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const updateSingleItemMock = vi.fn();
|
||||
const handleCommonMenuActionsMock = vi.fn(() => false);
|
||||
|
||||
const stateStub = {
|
||||
virtualScroller: { updateSingleItem: updateSingleItemMock },
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
setSessionItem: vi.fn(),
|
||||
removeSessionItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
updateRecipeMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: stateStub,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
||||
moveManager: { showMoveModal: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
|
||||
ModelContextMenuMixin: {
|
||||
handleCommonMenuActions: handleCommonMenuActionsMock,
|
||||
initNSFWSelector: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const flushAsyncTasks = async (rounds = 5) => {
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
|
||||
describe('RecipeContextMenu.rematchRecipe', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = `
|
||||
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||
<div class="context-menu-item" data-action="rematch"></div>
|
||||
<div class="context-menu-item download-missing-item" data-action="download-missing"></div>
|
||||
</div>
|
||||
<div id="card" class="model-card" data-id="recipe-1" data-filepath="/recipes/recipe-1.webp"></div>
|
||||
`;
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
async function createMenu() {
|
||||
const { RecipeContextMenu } = await import(
|
||||
'../../../static/js/components/ContextMenu/RecipeContextMenu.js'
|
||||
);
|
||||
return new RecipeContextMenu();
|
||||
}
|
||||
|
||||
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
|
||||
// mirror would fire the skipped toast here.
|
||||
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
global.fetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, rematched: 2, skipped: 0, matched_recipes: 1, matched_entries: 2 }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
{ rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 },
|
||||
'success'
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
|
||||
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
|
||||
id: 'recipe-1',
|
||||
title: 'Updated Recipe',
|
||||
});
|
||||
});
|
||||
|
||||
it('toasts an info message when the entries had no local match', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, rematched: 0, skipped: 0, unresolved_recipes: 1, unresolved_entries: 2 }),
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: 2, recipes: 1, total: 1 },
|
||||
'info'
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toasts the skipped message when nothing was rematched', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, rematched: 0, skipped: 1 }),
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
{ total: 1 },
|
||||
'info'
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Oracle R4-F2 pin: failure surfaces `result.error` (e.g. the 409 body).
|
||||
it('surfaces result.error when the rematch is rejected', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 409,
|
||||
json: async () => ({ success: false, error: 'Recipe rematch already in progress' }),
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
{ message: 'Recipe rematch already in progress' },
|
||||
'error'
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toasts the failure message when the fetch throws', async () => {
|
||||
const menu = await createMenu();
|
||||
const card = document.getElementById('card');
|
||||
menu.showMenu(100, 100, card);
|
||||
|
||||
global.fetch.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
document
|
||||
.querySelector('[data-action="rematch"]')
|
||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
{ message: 'network down' },
|
||||
'error'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
const rematchBulkModelsMock = vi.fn();
|
||||
const updateSingleItemMock = vi.fn();
|
||||
|
||||
const loadingManagerStub = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
};
|
||||
|
||||
const stateStub = {
|
||||
currentPageType: 'recipes',
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
loadingManager: loadingManagerStub,
|
||||
virtualScroller: { updateSingleItem: updateSingleItemMock },
|
||||
global: { settings: {} },
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: stateStub,
|
||||
getCurrentPageState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: showToastMock,
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
sendEmbeddingToWorkflow: vi.fn(),
|
||||
buildLoraSyntax: vi.fn(),
|
||||
getNSFWLevelName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
getModelApiClient: vi.fn(),
|
||||
resetAndReload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
RecipeSidebarApiClient: class {
|
||||
constructor() {
|
||||
this.rematchBulkModels = rematchBulkModelsMock;
|
||||
}
|
||||
},
|
||||
updateRecipeMetadata: vi.fn(),
|
||||
extractRecipeId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
||||
MODEL_CONFIG: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
updateCardsForBulkMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
|
||||
getPriorityTagSuggestions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
||||
getNsfwLevelSelector: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('BulkManager.rematchSelectedRecipes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stateStub.currentPageType = 'recipes';
|
||||
stateStub.bulkMode = false;
|
||||
stateStub.selectedModels.clear();
|
||||
});
|
||||
|
||||
async function createBulkManager() {
|
||||
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||
return new BulkManager();
|
||||
}
|
||||
|
||||
it('exposes the rematch action on the recipes page action config', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
|
||||
});
|
||||
|
||||
// Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind
|
||||
// `repaired` mirror would fire the skipped toast with count 0 here.
|
||||
it('toasts the rematched count when the bulk rematch succeeds', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
stateStub.selectedModels.add('/recipes/c.webp');
|
||||
|
||||
const rematchedRecipe = { file_path: '/recipes/a.webp', title: 'A' };
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 3,
|
||||
rematched: 4,
|
||||
skipped: 1,
|
||||
errors: 0,
|
||||
matched_recipes: 2,
|
||||
matched_entries: 4,
|
||||
unresolved_recipes: 1,
|
||||
unresolved_entries: 1,
|
||||
recipes: [rematchedRecipe],
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith([
|
||||
'/recipes/a.webp',
|
||||
'/recipes/b.webp',
|
||||
'/recipes/c.webp',
|
||||
]);
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
|
||||
'success'
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
|
||||
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
|
||||
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the errors toast variant when the bulk rematch has failures', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 2,
|
||||
rematched: 3,
|
||||
skipped: 0,
|
||||
errors: 2,
|
||||
matched_recipes: 1,
|
||||
matched_entries: 3,
|
||||
unresolved_recipes: 0,
|
||||
unresolved_entries: 0,
|
||||
recipes: [],
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchCompleteErrors',
|
||||
{ rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 },
|
||||
'warning'
|
||||
);
|
||||
});
|
||||
|
||||
it('toasts an error when every selected recipe failed to rematch', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 2,
|
||||
rematched: 0,
|
||||
skipped: 0,
|
||||
errors: 2,
|
||||
matched_recipes: 0,
|
||||
matched_entries: 0,
|
||||
unresolved_recipes: 0,
|
||||
unresolved_entries: 0,
|
||||
recipes: [],
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchAllFailed',
|
||||
{ total: 2, failures: 2 },
|
||||
'error'
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('toasts an info message when entries had no local match', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
stateStub.selectedModels.add('/recipes/c.webp');
|
||||
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 3,
|
||||
rematched: 0,
|
||||
skipped: 2,
|
||||
errors: 0,
|
||||
matched_recipes: 0,
|
||||
matched_entries: 0,
|
||||
unresolved_recipes: 1,
|
||||
unresolved_entries: 2,
|
||||
recipes: [],
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: 2, recipes: 1, total: 3 },
|
||||
'info'
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('toasts the skipped message when nothing was rematched', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
stateStub.selectedModels.add('/recipes/b.webp');
|
||||
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: true,
|
||||
total: 2,
|
||||
rematched: 0,
|
||||
skipped: 2,
|
||||
errors: 0,
|
||||
recipes: [],
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchSkipped',
|
||||
{ total: 2 },
|
||||
'info'
|
||||
);
|
||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchComplete',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the backend error message when the bulk rematch fails', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
|
||||
rematchBulkModelsMock.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Rematch already in progress',
|
||||
});
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
{ message: 'Rematch already in progress' },
|
||||
'error'
|
||||
);
|
||||
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toasts the failure message when the API call throws', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.selectedModels.add('/recipes/a.webp');
|
||||
|
||||
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.rematchFailed',
|
||||
{ message: 'network down' },
|
||||
'error'
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and does not call the API when nothing is selected', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.noRecipesSelected',
|
||||
{},
|
||||
'warning'
|
||||
);
|
||||
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and does not call the API outside the recipes page', async () => {
|
||||
const bulk = await createBulkManager();
|
||||
stateStub.currentPageType = 'loras';
|
||||
stateStub.selectedModels.add('/models/a.safetensors');
|
||||
|
||||
await bulk.rematchSelectedRecipes();
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'This operation is only available for recipes',
|
||||
{},
|
||||
'warning'
|
||||
);
|
||||
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -214,3 +214,78 @@ def test_recipe_routes_setup_routes_uses_registrar(monkeypatch: pytest.MonkeyPat
|
||||
}
|
||||
assert {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes.RecipeRoutes}
|
||||
assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies"}
|
||||
|
||||
|
||||
# --- Rematch route scaffolding ----------------------------------------------
|
||||
|
||||
_REMATCH_ROUTE_DEFS = {
|
||||
("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
|
||||
("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
|
||||
("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
|
||||
("POST", "/api/lm/recipes/cancel-rematch", "cancel_rematch"),
|
||||
("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
|
||||
}
|
||||
|
||||
_REPAIR_ROUTE_DEFS = {
|
||||
("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||
("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||
("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||
}
|
||||
|
||||
|
||||
def test_rematch_route_definitions_registered():
|
||||
registered = {
|
||||
(d.method, d.path, d.handler_name)
|
||||
for d in recipe_route_registrar.ROUTE_DEFINITIONS
|
||||
}
|
||||
assert _REMATCH_ROUTE_DEFS <= registered
|
||||
|
||||
|
||||
def test_repair_route_definitions_still_registered():
|
||||
registered = {
|
||||
(d.method, d.path, d.handler_name)
|
||||
for d in recipe_route_registrar.ROUTE_DEFINITIONS
|
||||
}
|
||||
assert _REPAIR_ROUTE_DEFS <= registered
|
||||
|
||||
|
||||
def test_rematch_handler_names_resolve_in_to_route_mapping(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Oracle R1-F4: register_routes KeyErrors at startup if to_route_mapping
|
||||
lacks any name present in ROUTE_DEFINITIONS, so the real handler set must
|
||||
resolve every rematch name.
|
||||
"""
|
||||
registry = service_registry.ServiceRegistry
|
||||
scanner = _make_stub_scanner()
|
||||
civitai_client = object()
|
||||
|
||||
async def fake_get_recipe_scanner():
|
||||
return scanner
|
||||
|
||||
async def fake_get_civitai_client():
|
||||
return civitai_client
|
||||
|
||||
async def fake_get_downloader():
|
||||
return object()
|
||||
|
||||
class _DummyService:
|
||||
def __init__(self, **_: Any) -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(registry, "get_recipe_scanner", fake_get_recipe_scanner)
|
||||
monkeypatch.setattr(registry, "get_civitai_client", fake_get_civitai_client)
|
||||
monkeypatch.setattr(base_recipe_routes, "RecipeAnalysisService", _DummyService)
|
||||
monkeypatch.setattr(base_recipe_routes, "RecipePersistenceService", _DummyService)
|
||||
monkeypatch.setattr(base_recipe_routes, "RecipeSharingService", _DummyService)
|
||||
monkeypatch.setattr(base_recipe_routes, "get_downloader", fake_get_downloader)
|
||||
|
||||
async def scenario():
|
||||
routes = base_recipe_routes.BaseRecipeRoutes()
|
||||
await routes.attach_dependencies()
|
||||
mapping = routes.to_route_mapping()
|
||||
for _, _, name in _REMATCH_ROUTE_DEFS:
|
||||
assert name in mapping
|
||||
assert asyncio.iscoroutinefunction(mapping[name])
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -14,6 +14,8 @@ from aiohttp import FormData, web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from PIL import Image
|
||||
|
||||
import pytest
|
||||
|
||||
from py.config import config
|
||||
from py.routes import base_recipe_routes
|
||||
from py.routes.handlers import recipe_handlers
|
||||
@@ -21,6 +23,7 @@ from py.routes.recipe_routes import RecipeRoutes
|
||||
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
|
||||
from py.services.recipes import RecipeValidationError, RecipeNotFoundError
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.services.websocket_manager import ws_manager
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -51,6 +54,13 @@ class StubRecipeScanner:
|
||||
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self.image_id_map_override: Dict[str, str] = {}
|
||||
self.local_hash_cache: Dict[str, Dict[str, Any]] | None = None
|
||||
# Rematch double bookkeeping
|
||||
self.cancel_calls = 0
|
||||
self.reset_calls = 0
|
||||
self.rematch_all_calls: List[Any] = []
|
||||
self.rematch_by_id_calls: List[str] = []
|
||||
self.rematch_bulk_calls: List[List[str]] = []
|
||||
self.rematch_results: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
|
||||
return None
|
||||
@@ -106,6 +116,65 @@ class StubRecipeScanner:
|
||||
self.removed.append(recipe_id)
|
||||
self.recipes.pop(recipe_id, None)
|
||||
|
||||
def cancel_task(self) -> None:
|
||||
self.cancel_calls += 1
|
||||
|
||||
def reset_cancellation(self) -> None:
|
||||
self.reset_calls += 1
|
||||
|
||||
async def rematch_all_recipes(self, progress_callback=None):
|
||||
"""Run a canned rematch-all run, mirroring the real progress events."""
|
||||
if progress_callback:
|
||||
await progress_callback({"status": "started"})
|
||||
await progress_callback(
|
||||
{"status": "processing", "current": 1, "total": 1, "recipe_name": "demo"}
|
||||
)
|
||||
await progress_callback(
|
||||
{"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1}
|
||||
)
|
||||
self.rematch_all_calls.append(progress_callback)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "completed",
|
||||
"rematched": 1,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"total": 1,
|
||||
}
|
||||
|
||||
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
self.rematch_by_id_calls.append(recipe_id)
|
||||
if recipe_id not in self.rematch_results:
|
||||
raise RecipeNotFoundError(f"Recipe not found: {recipe_id}")
|
||||
return self.rematch_results[recipe_id]
|
||||
|
||||
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
||||
self.rematch_bulk_calls.append(list(recipe_ids))
|
||||
total = len(recipe_ids)
|
||||
rematched = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
recipes: List[Dict[str, Any]] = []
|
||||
for recipe_id in recipe_ids:
|
||||
result = self.rematch_results.get(recipe_id)
|
||||
if result is None:
|
||||
skipped += 1
|
||||
elif result.get("success"):
|
||||
rematched += result.get("rematched", 0)
|
||||
skipped += result.get("skipped", 0)
|
||||
if result.get("recipe"):
|
||||
recipes.append(result["recipe"])
|
||||
else:
|
||||
errors += 1
|
||||
return {
|
||||
"success": True,
|
||||
"total": total,
|
||||
"rematched": rematched,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"recipes": recipes,
|
||||
}
|
||||
|
||||
|
||||
class StubAnalysisService:
|
||||
"""Captures calls made by analysis routes while returning canned responses."""
|
||||
@@ -1721,3 +1790,164 @@ async def test_create_from_example_does_not_recompute_stored_autov3(
|
||||
assert parser.received_cache is harness.scanner.local_hash_cache
|
||||
assert parser.received_cache is not None
|
||||
assert parser.received_cache["existing123456"] is parent_item
|
||||
|
||||
|
||||
# --- Rematch endpoints ------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_recipe_run_progress_state():
|
||||
"""Keep the shared WS manager run-state isolated between tests."""
|
||||
ws_manager._recipe_rematch_progress = None
|
||||
ws_manager._recipe_repair_progress = None
|
||||
yield
|
||||
ws_manager._recipe_rematch_progress = None
|
||||
ws_manager._recipe_repair_progress = None
|
||||
|
||||
|
||||
def _set_rematch_running(status: str = "processing") -> None:
|
||||
ws_manager._recipe_rematch_progress = {"status": status}
|
||||
|
||||
|
||||
def _set_repair_running(status: str = "processing") -> None:
|
||||
ws_manager._recipe_repair_progress = {"status": status}
|
||||
|
||||
|
||||
async def test_rematch_recipes_starts_background_run(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post("/api/lm/recipes/rematch")
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
assert payload["success"] is True
|
||||
assert payload["message"] == "Recipe rematch started"
|
||||
assert harness.scanner.reset_calls == 1
|
||||
# Allow the spawned background task to reach its progress broadcasts.
|
||||
await asyncio.sleep(0.1)
|
||||
assert harness.scanner.rematch_all_calls == [harness.scanner.rematch_all_calls[0]]
|
||||
|
||||
|
||||
async def test_rematch_recipes_409_when_rematch_running(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
_set_rematch_running()
|
||||
response = await harness.client.post("/api/lm/recipes/rematch")
|
||||
payload = await response.json()
|
||||
assert response.status == 409
|
||||
assert payload["success"] is False
|
||||
assert "already in progress" in payload["error"].lower()
|
||||
|
||||
|
||||
async def test_rematch_recipes_409_when_repair_running(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
_set_repair_running()
|
||||
response = await harness.client.post("/api/lm/recipes/rematch")
|
||||
payload = await response.json()
|
||||
assert response.status == 409
|
||||
assert payload["success"] is False
|
||||
assert "already in progress" in payload["error"].lower()
|
||||
|
||||
|
||||
async def test_rematch_recipe_409_when_rematch_running(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
_set_rematch_running()
|
||||
response = await harness.client.post("/api/lm/recipe/abc123/rematch")
|
||||
payload = await response.json()
|
||||
assert response.status == 409
|
||||
assert payload["success"] is False
|
||||
assert harness.scanner.rematch_by_id_calls == []
|
||||
|
||||
|
||||
async def test_rematch_recipes_bulk_409_when_rematch_running(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
_set_rematch_running()
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipes/rematch-bulk", json={"recipe_ids": ["abc123"]}
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 409
|
||||
assert payload["success"] is False
|
||||
assert harness.scanner.rematch_bulk_calls == []
|
||||
|
||||
|
||||
async def test_cancel_rematch_calls_scanner_cancel_task(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post("/api/lm/recipes/cancel-rematch")
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["message"] == "Cancellation requested"
|
||||
assert harness.scanner.cancel_calls == 1
|
||||
|
||||
|
||||
async def test_rematch_recipes_bulk_parses_ids_and_returns_summary(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.rematch_results = {
|
||||
"r1": {
|
||||
"success": True,
|
||||
"rematched": 2,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"recipe": {"id": "r1", "title": "Found"},
|
||||
},
|
||||
}
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipes/rematch-bulk",
|
||||
json={"recipe_ids": ["r1", "missing-id"]},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200, payload
|
||||
# The loop is delegated to the scanner, not re-implemented in the handler.
|
||||
assert harness.scanner.rematch_bulk_calls == [["r1", "missing-id"]]
|
||||
assert harness.scanner.rematch_by_id_calls == []
|
||||
assert payload["success"] is True
|
||||
assert payload["total"] == 2
|
||||
assert payload["rematched"] == 2
|
||||
assert payload["skipped"] == 1 # missing-id counted as skipped
|
||||
assert payload["errors"] == 0
|
||||
assert payload["recipes"] == [{"id": "r1", "title": "Found"}]
|
||||
|
||||
|
||||
async def test_rematch_recipes_bulk_missing_recipe_ids_400(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipes/rematch-bulk", json={"recipe_ids": []}
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 400
|
||||
assert payload["success"] is False
|
||||
assert "recipe_ids" in payload["error"].lower()
|
||||
|
||||
|
||||
async def test_rematch_recipe_maps_not_found_to_404(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post("/api/lm/recipe/ghost/rematch")
|
||||
payload = await response.json()
|
||||
assert response.status == 404
|
||||
assert payload["success"] is False
|
||||
assert harness.scanner.rematch_by_id_calls == ["ghost"]
|
||||
|
||||
|
||||
async def test_get_rematch_progress_404_when_no_progress(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get("/api/lm/recipes/rematch-progress")
|
||||
payload = await response.json()
|
||||
assert response.status == 404
|
||||
assert payload["success"] is False
|
||||
|
||||
|
||||
async def test_get_rematch_progress_returns_stored_progress(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
_set_rematch_running("processing")
|
||||
response = await harness.client.get("/api/lm/recipes/rematch-progress")
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["progress"]["status"] == "processing"
|
||||
|
||||
@@ -6,7 +6,12 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.services.aria2_downloader import Aria2Downloader, Aria2Error, Aria2Transfer
|
||||
from py.services.aria2_downloader import (
|
||||
Aria2Downloader,
|
||||
Aria2Error,
|
||||
Aria2Transfer,
|
||||
MAX_TRANSFER_RECOVERY_ATTEMPTS,
|
||||
)
|
||||
from py.services.aria2_transfer_state import Aria2TransferStateStore
|
||||
from py.services import aria2_transfer_state
|
||||
|
||||
@@ -246,6 +251,253 @@ async def test_download_file_reuses_existing_transfer_without_add_uri(
|
||||
assert [call[0] for call in rpc_calls] == ["aria2.tellStatus", "aria2.tellStatus"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_recovers_when_transfer_lost_mid_poll(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
add_uri_count = {"n": 0}
|
||||
poll_count = {"n": 0}
|
||||
|
||||
async def fake_rpc_call(method, params):
|
||||
if method == "aria2.addUri":
|
||||
add_uri_count["n"] += 1
|
||||
return "gid-1" if add_uri_count["n"] == 1 else "gid-2"
|
||||
if method == "aria2.tellStatus":
|
||||
poll_count["n"] += 1
|
||||
if poll_count["n"] == 1:
|
||||
# Simulate a concurrent close() wiping the transfer mid-poll.
|
||||
downloader._transfers.pop("download-1", None)
|
||||
return {
|
||||
"gid": "gid-1",
|
||||
"status": "active",
|
||||
"completedLength": "5",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "25",
|
||||
}
|
||||
return {
|
||||
"gid": "gid-2",
|
||||
"status": "complete",
|
||||
"completedLength": "10",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "0",
|
||||
"files": [{"path": str(save_path)}],
|
||||
}
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert result == str(save_path)
|
||||
assert add_uri_count["n"] == 2
|
||||
assert downloader._transfers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_recovers_when_rpc_fails_mid_poll(tmp_path, monkeypatch):
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
add_uri_count = {"n": 0}
|
||||
poll_count = {"n": 0}
|
||||
|
||||
async def fake_rpc_call(method, params):
|
||||
if method == "aria2.addUri":
|
||||
add_uri_count["n"] += 1
|
||||
return "gid-1" if add_uri_count["n"] == 1 else "gid-2"
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
async def fake_get_status_with_retry(download_id):
|
||||
poll_count["n"] += 1
|
||||
if poll_count["n"] == 1:
|
||||
raise Aria2Error(
|
||||
"Failed to query aria2 download status after 4 attempts: boom"
|
||||
)
|
||||
return {
|
||||
"gid": "gid-2",
|
||||
"status": "complete",
|
||||
"completedLength": "10",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "0",
|
||||
"files": [{"path": str(save_path)}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
monkeypatch.setattr(downloader, "_get_status_with_retry", fake_get_status_with_retry)
|
||||
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert result == str(save_path)
|
||||
assert add_uri_count["n"] == 2
|
||||
assert downloader._transfers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_fails_after_recovery_attempts_exhausted(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
add_uri_count = {"n": 0}
|
||||
|
||||
async def fake_rpc_call(method, params):
|
||||
if method == "aria2.addUri":
|
||||
add_uri_count["n"] += 1
|
||||
return f"gid-{add_uri_count['n']}"
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
async def fake_get_status(download_id):
|
||||
return None # transfer never tracked / always lost
|
||||
|
||||
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
monkeypatch.setattr(downloader, "get_status", fake_get_status)
|
||||
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert result == "aria2 download not found"
|
||||
assert add_uri_count["n"] == 1 + MAX_TRANSFER_RECOVERY_ATTEMPTS
|
||||
assert downloader._transfers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_concurrent_same_id_schedules_once(tmp_path, monkeypatch):
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
add_uri_count = {"n": 0}
|
||||
poll_count = {"n": 0}
|
||||
|
||||
async def fake_rpc_call(method, params):
|
||||
if method == "aria2.addUri":
|
||||
add_uri_count["n"] += 1
|
||||
return "gid-1"
|
||||
if method == "aria2.tellStatus":
|
||||
poll_count["n"] += 1
|
||||
if poll_count["n"] < 4:
|
||||
return {
|
||||
"gid": "gid-1",
|
||||
"status": "active",
|
||||
"completedLength": "5",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "25",
|
||||
}
|
||||
return {
|
||||
"gid": "gid-1",
|
||||
"status": "complete",
|
||||
"completedLength": "10",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "0",
|
||||
"files": [{"path": str(save_path)}],
|
||||
}
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||
|
||||
results = await asyncio.gather(
|
||||
downloader.download_file(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
),
|
||||
downloader.download_file(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
),
|
||||
)
|
||||
|
||||
assert all(success for success, _ in results)
|
||||
assert all(result == str(save_path) for _, result in results)
|
||||
assert add_uri_count["n"] <= 2
|
||||
assert downloader._transfers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_file_cleanup_preserves_newer_registration(tmp_path, monkeypatch):
|
||||
downloader = Aria2Downloader()
|
||||
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
|
||||
downloader._rpc_secret = "secret"
|
||||
|
||||
save_path = tmp_path / "downloads" / "model.safetensors"
|
||||
poll_count = {"n": 0}
|
||||
|
||||
async def fake_rpc_call(method, params):
|
||||
if method == "aria2.addUri":
|
||||
return "gid-1"
|
||||
if method == "aria2.tellStatus":
|
||||
poll_count["n"] += 1
|
||||
if poll_count["n"] == 1:
|
||||
# Simulate another invocation registering its own transfer.
|
||||
downloader._transfers["download-1"] = Aria2Transfer(
|
||||
gid="gid-new", save_path=str(save_path)
|
||||
)
|
||||
return {
|
||||
"gid": "gid-1",
|
||||
"status": "active",
|
||||
"completedLength": "5",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "25",
|
||||
}
|
||||
return {
|
||||
"gid": "gid-new",
|
||||
"status": "complete",
|
||||
"completedLength": "10",
|
||||
"totalLength": "10",
|
||||
"downloadSpeed": "0",
|
||||
"files": [{"path": str(save_path)}],
|
||||
}
|
||||
raise AssertionError(f"Unexpected RPC method: {method}")
|
||||
|
||||
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
|
||||
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
|
||||
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
|
||||
|
||||
success, result = await downloader.download_file(
|
||||
"https://example.com/model.safetensors",
|
||||
str(save_path),
|
||||
download_id="download-1",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert result == str(save_path)
|
||||
assert downloader._transfers["download-1"].gid == "gid-new"
|
||||
|
||||
|
||||
def test_build_progress_snapshot_normalizes_numeric_fields():
|
||||
downloader = Aria2Downloader()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,3 +172,92 @@ def test_generate_download_id(manager):
|
||||
download_id = manager.generate_download_id()
|
||||
assert isinstance(download_id, str)
|
||||
assert download_id
|
||||
|
||||
|
||||
# --- Recipe rematch progress channel ---
|
||||
|
||||
|
||||
async def test_broadcast_recipe_rematch_progress_stores_and_broadcasts(manager, monkeypatch):
|
||||
payload = {"status": "started", "total": 3}
|
||||
broadcast_calls = []
|
||||
|
||||
async def fake_broadcast(data):
|
||||
broadcast_calls.append(data)
|
||||
|
||||
monkeypatch.setattr(manager, "broadcast", fake_broadcast)
|
||||
|
||||
await manager.broadcast_recipe_rematch_progress(payload)
|
||||
|
||||
assert broadcast_calls == [payload]
|
||||
assert manager.get_recipe_rematch_progress() == payload
|
||||
|
||||
|
||||
async def test_get_recipe_rematch_progress_returns_stored(manager):
|
||||
assert manager.get_recipe_rematch_progress() is None
|
||||
|
||||
payload = {"status": "processing", "current": 2, "total": 5}
|
||||
await manager.broadcast_recipe_rematch_progress(payload)
|
||||
|
||||
assert manager.get_recipe_rematch_progress() == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,should_clear",
|
||||
[
|
||||
("started", False),
|
||||
("processing", False),
|
||||
("completed", True),
|
||||
("cancelled", True),
|
||||
("error", True),
|
||||
],
|
||||
)
|
||||
async def test_cleanup_recipe_rematch_progress_only_on_terminal(manager, status, should_clear):
|
||||
await manager.broadcast_recipe_rematch_progress({"status": status})
|
||||
|
||||
manager.cleanup_recipe_rematch_progress()
|
||||
|
||||
if should_clear:
|
||||
assert manager.get_recipe_rematch_progress() is None
|
||||
else:
|
||||
assert manager.get_recipe_rematch_progress() == {"status": status}
|
||||
|
||||
|
||||
async def test_is_recipe_rematch_running_false_without_progress(manager):
|
||||
assert manager.is_recipe_rematch_running() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,expected",
|
||||
[
|
||||
("started", True),
|
||||
("processing", True),
|
||||
("completed", False),
|
||||
("cancelled", False),
|
||||
("error", False),
|
||||
],
|
||||
)
|
||||
async def test_is_recipe_rematch_running_by_status(manager, status, expected):
|
||||
await manager.broadcast_recipe_rematch_progress({"status": status})
|
||||
|
||||
assert manager.is_recipe_rematch_running() is expected
|
||||
|
||||
|
||||
async def test_rematch_and_repair_channels_are_independent(manager):
|
||||
# Rematch progress must not leak into the repair channel
|
||||
await manager.broadcast_recipe_rematch_progress({"status": "processing", "current": 1})
|
||||
assert manager.is_recipe_rematch_running() is True
|
||||
assert manager.is_recipe_repair_running() is False
|
||||
assert manager.get_recipe_repair_progress() is None
|
||||
|
||||
# Repair progress must not overwrite the rematch state
|
||||
await manager.broadcast_recipe_repair_progress({"status": "processing", "current": 1})
|
||||
assert manager.is_recipe_repair_running() is True
|
||||
assert manager.is_recipe_rematch_running() is True
|
||||
assert manager.get_recipe_rematch_progress() == {"status": "processing", "current": 1}
|
||||
|
||||
# Cleaning the rematch channel must leave the repair channel untouched
|
||||
await manager.broadcast_recipe_rematch_progress({"status": "completed"})
|
||||
manager.cleanup_recipe_rematch_progress()
|
||||
assert manager.get_recipe_rematch_progress() is None
|
||||
assert manager.get_recipe_repair_progress() == {"status": "processing", "current": 1}
|
||||
assert manager.is_recipe_repair_running() is True
|
||||
|
||||
Reference in New Issue
Block a user