Compare commits

...
10 Commits
38 changed files with 5183 additions and 181 deletions
+209 -37
View File
@@ -1,47 +1,145 @@
--- ---
name: lora-manager-e2e name: lora-manager-e2e
description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode, including starting/restarting the server, using Chrome DevTools MCP to interact with the web UI at http://127.0.0.1:8188/loras, and verifying frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode in a SANDBOXED, disposable configuration: check the port, start/restart the standalone server on a free port, use Chrome DevTools MCP to interact with the web UI (http://127.0.0.1:{PORT}/loras), and verify frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox.
--- ---
# LoRa Manager E2E Testing # LoRa Manager E2E Testing
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP. This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
## Conventions Used in This Document
- **`{PORT}`**: The server port. The default candidate is `8188`, but **`8188` is commonly occupied by a live ComfyUI process** and MUST NOT be assumed to be free. Always check availability first (see [Port Selection](#port-selection)) and use a free port (e.g. `8199`) for the E2E run. Substitute the actual port for every `{PORT}` in the commands below.
- **`<repo-root>`**: The repository/worktree root. Always run commands from the repo or worktree root; never assume a specific absolute path (paths such as `/home/<user>/...` differ per machine). The E2E scripts resolve the project root themselves, but fixture/settings paths are relative to `<repo-root>`.
## SANDBOX (MANDATORY)
> **Read this section before running anything.** Every E2E run MUST target a throwaway sandbox, never the real user data. A fresh subagent that skips this section WILL permanently mutate real user recipes.
1. **Portable settings**: create `<repo-root>/settings.json` (gitignored) with `"use_portable_settings": true` plus sandboxed `folder_paths` (lora/checkpoint roots) and `recipes_path`. This keeps the configuration inside the repo instead of the real user config dir (`~/.config/ComfyUI-LoRA-Manager/settings.json`).
2. **Sandboxed paths**: point `folder_paths` / `recipes_path` / `example_images_path` at disposable dirs — e.g. under `/tmp/opencode/<plan-name>-e2e/` (or worktree-local dirs). NEVER point the E2E at the real library (`~/models/...`), real recipe dir, or real settings.
3. **Never touch the real config**: the real user config at `~/.config/ComfyUI-LoRA-Manager/settings.json` and the real recipe dir must remain byte-identical before and after the run.
4. **Record real-data protection proof** before starting and after finishing:
```bash
# BEFORE: snapshot real config + recipe library state
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > /tmp/opencode/<plan>-e2e/settings.before.sha256
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > /tmp/opencode/<plan>-e2e/recipes-count.before.txt
find ~/models/recipes -name '*.recipe.json' -newermt "$(date -Iseconds)" | head # expect empty after run
# AFTER: record again, then diff the two snapshots. Any change = the run leaked into real data.
```
Also confirm `<repo-root>/git status` stays clean for `settings.json`/`cache/` (both are gitignored).
### Portable Settings Example
```json
{
"use_portable_settings": true,
"folder_paths": {
"loras": ["/tmp/opencode/<plan>-e2e/models/loras"],
"checkpoints": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
"unet": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
"diffusers": []
},
"recipes_path": "/tmp/opencode/<plan>-e2e/recipes",
"example_images_path": "/tmp/opencode/<plan>-e2e/example_images"
}
```
The scanner computes and persists model hashes during the library scan, so the sandbox model dirs just need the model files + `.metadata.json` sidecars (see [Fixture + Fresh-State Guidance](#fixture--fresh-state-guidance)).
## Time Budgets & Abort Guidance
A fresh subagent should complete a sandboxed standalone E2E **in well under 30 minutes**. Budget each phase:
| Phase | Expected duration | Abort if |
| --- | --- | --- |
| Port check + sandbox setup | < 2 min | — |
| Server start (detached) + readiness | < 30 s | > 60 s (2x) → stop |
| Chrome DevTools MCP connect | < 1 min | > 2 min → stop |
| Per entry-point run (after fixtures ready) | < 5 min | > 10 min (2x) → stop |
| Fixture reset + cache clear between runs | < 1 min | > 2 min → stop |
**Abort rule**: if a phase exceeds ~2x its budget, OR any single tool call fails/retries 3+ times in a row, **STOP**. Do not loop or retry blindly. Report `BLOCKED` with: the phase, the last observed state (server PID + `ss -tlnp` output, page snapshot, last API response), and the suspected cause. Record the partial state as evidence; a clean BLOCKED report is more valuable than an hour of retries.
## Prerequisites ## Prerequisites
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`) - LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`) — run everything from `<repo-root>`
- Chrome browser available for debugging - Chrome browser available for debugging
- Chrome DevTools MCP connected - Chrome DevTools MCP connected
- `ss` (or `lsof`/`netstat`) available for port checks: `ss -tlnp`
## Quick Start Workflow ## Port Selection
### 1. Start LoRa Manager Standalone `8188` is only the *default candidate*. Verify it is actually free before every run:
```python
# Use the provided script to start the server
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188
```
Or manually:
```bash
cd /home/miao/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager
python standalone.py --port 8188
```
Wait for server ready message before proceeding.
### 2. Open Chrome Debug Mode
```bash ```bash
# Chrome with remote debugging on port 9222 # Is anything listening on 8188?
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras ss -tlnp | grep ':8188' || echo "8188 is free"
``` ```
### 3. Connect Chrome DevTools MCP - If a process holds `8188` (e.g. a live ComfyUI — pid 6575 on this machine), pick a different free port, e.g. `8199`:
```bash
ss -tlnp | grep ':8199' || echo "8199 is free"
```
- **Never** kill a process you did not start for this E2E. The live ComfyUI is off-limits. Pick a free port instead.
- Use your chosen port for **all** subsequent commands (server, Chrome launch, browser URLs).
Ensure the MCP server is connected to Chrome at `http://localhost:9222`. ## Quick Start Workflow (sandboxed)
### 4. Navigate and Interact ### 1. Prepare the sandbox
```bash
cd <repo-root> # ALWAYS run from the repo/worktree root
mkdir -p /tmp/opencode/<plan>-e2e/models/{loras,checkpoints}
mkdir -p /tmp/opencode/<plan>-e2e/{recipes,example_images,recipes-before}
# write <repo-root>/settings.json per the portable-settings example above
# record real-data protection proof (see SANDBOX section)
```
### 2. Check port availability
```bash
ss -tlnp | grep ':{PORT}' || echo "port {PORT} is free"
```
If `{PORT}` is occupied by an unrelated process, pick a free one and use it everywhere below. When in doubt use `8199`.
### 3. Start LoRa Manager Standalone (detached)
The standalone server **dies with the shell unless launched fully detached** — a plain background `&` from the bash tool is killed when the tool call returns. Launch via the helper script:
```bash
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --wait --timeout 30 --detach
```
Or manually (equivalent detached form):
```bash
setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 < /dev/null \
>> /tmp/opencode/<plan>-e2e/server.log 2>&1 &
echo "started" # record the printed/pidfile PID for cleanup
```
Verify it is listening **before** proceeding (readiness poll is not a substitute for this):
```bash
ss -tlnp | grep ':{PORT}'
```
Record the server PID for cleanup: the helper script writes it to `/tmp/lora-manager-e2e-server-{PORT}.pid`; a manual `setsid` launch has no pidfile, so capture it explicitly (e.g. from `ss -tlnp`).
### 4. Open Chrome Debug Mode
```bash
# Chrome with remote debugging on port 9222 (note the {PORT} URL)
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras
```
### 5. Connect Chrome DevTools MCP
Ensure the MCP server is connected to Chrome at `http://localhost:9222`. Verify with `list_pages` — if it fails with "browser is already running", see [Chrome DevTools MCP Troubleshooting](#chrome-devtools-mcp-troubleshooting).
### 6. Navigate and Interact
Use Chrome DevTools MCP tools to: Use Chrome DevTools MCP tools to:
- Take snapshots: `take_snapshot` - Take snapshots: `take_snapshot`
@@ -56,7 +154,7 @@ Use Chrome DevTools MCP tools to:
```python ```python
# Navigate to LoRA list page # Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras") navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
# Wait for page to load # Wait for page to load
wait_for(text="LoRAs", timeout=10000) wait_for(text="LoRAs", timeout=10000)
@@ -68,9 +166,10 @@ snapshot = take_snapshot()
### Pattern: Restart Server for Configuration Changes ### Pattern: Restart Server for Configuration Changes
```python ```python
# Stop current server (if running) # Stop current server (if running), start with new configuration.
# Start with new configuration # --restart only kills the E2E server this script started before (via its pidfile);
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188 --restart # it refuses to blindly kill unrelated processes on the port.
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --detach
# Wait and refresh browser # Wait and refresh browser
navigate_page(type="reload", ignoreCache=True) navigate_page(type="reload", ignoreCache=True)
@@ -130,24 +229,96 @@ click(uid="modal-submit-button")
wait_for(text="Success", timeout=5000) wait_for(text="Success", timeout=5000)
``` ```
## Fixture + Fresh-State Guidance
For rematch/repair E2E runs, seed the **sandboxed** `recipes_path` with hand-written fixture recipes. Rules (validated by the task-8 E2E):
1. **Filename constraint**: each file MUST be named `f"{id}.recipe.json"` **and** the in-JSON `id` field MUST equal the filename. Discovery accepts any `*.recipe.json`, but persistence resolves the path via `get_recipe_json_path` and `_save_recipe_persistently` returns `False` on a mismatch → the fixture would be counted as an error.
- `recipe-a.recipe.json` → in-JSON `"id": "recipe-a"`
2. **File format**: mirror an existing recipe JSON — top-level `id`, `file_path`, `title`, `loras`, `fingerprint`, `gen_params`; lora entries per the persistence conventions (`hash`, `file_name`, `modelVersionId`, `isDeleted`, ...).
3. **Companion image**: each recipe needs an image (e.g. a `.webp` generated with PIL) referenced by `file_path`, used for EXIF verification (`ExifUtils.append_recipe_metadata` writes a `"Recipe metadata: ..."` marker; a freshly generated `.webp` with no marker is the clean "untouched" control).
4. **autov3 three-state contract**: for L3 (autov3-only, renamed-file) fixtures the local model's `.metadata.json` sidecar MUST have the `autov3` key **ABSENT** (the "unchecked" state), NOT `""` — `""` is the TERMINAL "checked but unavailable" state that L3 deliberately skips. The scanner computes + persists `autov3` from the file header during the normal library scan (`model_scanner.py` `_process_model_file`), so the live L3 match resolves through the local autov3/hash cache; the computed-autov3 branch for unchecked items is covered by the unit suite.
5. **Fixture design for a rematch run** (mirrors the task-8 E2E):
- `recipe-a`: lora entry `isDeleted=True`, `hash` = 12-char autov3 computed from the local model (`calculate_autov3`, `py/utils/file_utils.py`), whose local model file was RENAMED after the recipe was written so `file_name` differs (proves L3 match without filename).
- `recipe-b`: parser-convention checkpoint entry (uses `id`, no `modelVersionId`) matching a local checkpoint via L2 — the local checkpoint's `.metadata.json` MUST carry civitai version data with that `id` so `version_index` contains it (L2 cannot match otherwise).
- `recipe-c`: healthy recipe (no deleted entries) → must remain untouched.
### Fresh state between entry-point runs
Each entry point (global / per-recipe / selection-bulk) must start from the same deleted state. Between runs:
```bash
# 1. Reset fixtures to the before-state snapshot (copy back from recipes-before/)
cp /tmp/opencode/<plan>-e2e/recipes-before/*.recipe.json /tmp/opencode/<plan>-e2e/recipes/
# 2. Clear the recipe/FTS caches so the stale in-memory/library state is gone
rm -f <repo-root>/cache/recipe/*.sqlite
rm -rf <repo-root>/cache/fts/*
# 3. Restart the server (fresh process, fresh scan)
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach
# 4. Re-verify server listening + reload the browser page
```
## Server Lifecycle
- **Detached launch is mandatory**: the standalone server dies with the shell unless launched via `setsid` (or the helper script's `--detach`). Use `setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 ... < /dev/null &`.
- **Verify with `ss -tlnp`** after every (re)start; do not proceed on a blind "server starting" message.
- **Never kill pre-existing processes** — only kill the E2E server PID you started (`start_server.py --restart` kills only PIDs it manages via its pidfile). The live ComfyUI or a stale QA Chrome must never be killed as part of cleanup unless explicitly identified as such (see Chrome troubleshooting).
- **Record your PID for cleanup**: note the PID printed/pidfile, and stop exactly that PID at the end (`kill <PID>`, then confirm with `ss -tlnp` that `{PORT}` is released).
## Chrome DevTools MCP Troubleshooting
### Stale profile lock ("browser is already running" / `list_pages` fails)
A Chrome profile can be held by a stale Chrome from a prior MCP session, which makes `list_pages` fail with "browser is already running":
1. Identify the stale Chrome — it owns the profile dir in `--user-data-dir` (e.g. `~/.config/chrome-dev-profile`). Find its process:
```bash
ps -ef | grep -i '[c]hrome.*user-data-dir'
```
2. Confirm it is a QA Chrome from a completed task (its parent is an old MCP/browser process, it is NOT the live ComfyUI server, and it is NOT your current MCP instance).
3. Kill ONLY that stale Chrome:
```bash
kill <stale-chrome-pid>
```
Never kill the live server or unrelated processes.
4. Retry `list_pages`. The current MCP will spawn a fresh browser.
### Screenshot-write restrictions
The chrome-devtools MCP may refuse to write into paths outside its configured workspace roots (e.g. the worktree `.omo/evidence/...` canonicalizing to an unmapped path). Workaround:
```bash
# 1. Save the screenshot to /tmp via the MCP
# take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
# 2. Copy it into the evidence dir from the shell
mkdir -p <repo-root>/.omo/evidence/screenshots
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
```
## Cancellation Testing (KNOWN GAP)
Testing the rematch-cancel path E2E requires a run long enough to cancel mid-flight. A tiny 3-recipe fixture set completes in **seconds** — too fast to reliably cancel. The cancel path is currently **unit-covered only** (`rematch_all_recipes` cancellation tests); do not block an E2E run on cancel-path verification. If you must attempt it, you would need an artificially large/deferred fixture set to create a cancellable window — treat this as a research task, not part of the standard E2E.
## Available Scripts ## Available Scripts
### scripts/start_server.py ### scripts/start_server.py
Starts or restarts the LoRa Manager standalone server. Starts or restarts the LoRa Manager standalone server for E2E testing.
```bash ```bash
python scripts/start_server.py [--port PORT] [--restart] [--wait] python scripts/start_server.py [--port PORT] [--restart] [--wait] [--timeout SECONDS] [--detach]
``` ```
Options: Options:
- `--port`: Server port (default: 8188) - `--port`: Server port (default: 8188). The script exits early with a clear message if the port is already in use by an unrelated process.
- `--restart`: Kill existing server before starting - `--restart`: Kill the E2E server this script previously managed (tracked via `/tmp/lora-manager-e2e-server-{PORT}.pid`) before starting. If unrelated processes still hold the port after that, the script reports them and aborts instead of killing them.
- `--wait`: Wait for server to be ready before exiting - `--wait`: Wait for the server to be ready before exiting.
- `--timeout`: Readiness wait timeout in seconds (default: 30).
- `--detach`: Launch the server fully detached (`setsid`-style, survives shell death — REQUIRED for E2E). Default off: a normal background process that dies with the shell.
### scripts/wait_for_server.py ### scripts/wait_for_server.py
Polls server until ready or timeout. Polls the server until ready or timeout.
```bash ```bash
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS] python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
@@ -196,6 +367,7 @@ results = performance_stop_trace()
## Cleanup ## Cleanup
Always ensure proper cleanup after tests: Always ensure proper cleanup after tests:
1. Stop the standalone server 1. Stop the standalone server: `kill <recorded-pid>` (only the PID you started), then confirm `ss -tlnp | grep ':{PORT}'` is empty.
2. Close browser pages (keep at least one open) 2. Close browser pages (keep at least one open).
3. Clear temporary data if needed 3. Remove the sandbox: `rm -rf /tmp/opencode/<plan>-e2e` and `<repo-root>/settings.json` + `<repo-root>/cache` (both gitignored).
4. Re-run the real-data protection check from the SANDBOX section and record the result in your evidence.
@@ -2,11 +2,13 @@
Quick reference for common MCP commands used in LoRa Manager E2E testing. Quick reference for common MCP commands used in LoRa Manager E2E testing.
> **Port convention**: `{PORT}` is the port chosen for the E2E run (default candidate `8188`, but only if actually free — see the SKILL.md Port Selection section; use e.g. `8199` when `8188` is occupied by a live ComfyUI). Always run against the **sandboxed** standalone server, never a live instance.
## Navigation ## Navigation
```python ```python
# Navigate to LoRA list page # Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras") navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
# Reload page with cache clear # Reload page with cache clear
navigate_page(type="reload", ignoreCache=True) navigate_page(type="reload", ignoreCache=True)
@@ -179,7 +181,7 @@ pages = list_pages()
select_page(pageId=0, bringToFront=True) select_page(pageId=0, bringToFront=True)
# Create new page # Create new page
new_page(url="http://127.0.0.1:8188/loras") new_page(url="http://127.0.0.1:{PORT}/loras")
# Close page (keep at least one open!) # Close page (keep at least one open!)
close_page(pageId=1) close_page(pageId=1)
@@ -261,7 +263,7 @@ drag(from_uid="draggable-item", to_uid="drop-zone")
### Verify LoRA Cards Loaded ### Verify LoRA Cards Loaded
```python ```python
navigate_page(type="url", url="http://127.0.0.1:8188/loras") navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
wait_for(text="LoRAs", timeout=10000) wait_for(text="LoRAs", timeout=10000)
# Check if cards loaded # Check if cards loaded
@@ -322,3 +324,37 @@ navigate_page(type="reload")
errors = list_console_messages(types=["error"]) errors = list_console_messages(types=["error"])
assert len(errors) == 0, f"Console errors: {errors}" assert len(errors) == 0, f"Console errors: {errors}"
``` ```
## Troubleshooting
### Stale profile lock ("browser is already running" / `list_pages` fails)
A Chrome profile held by a stale Chrome from a prior MCP session makes `list_pages`
fail with "browser is already running". Fix:
1. Find the stale Chrome that owns the profile dir (e.g. `~/.config/chrome-dev-profile`):
```bash
ps -ef | grep -i '[c]hrome.*user-data-dir'
```
2. Confirm it is a QA Chrome from a completed task (NOT the live ComfyUI server, NOT
your current MCP instance).
3. Kill ONLY that stale Chrome (`kill <stale-pid>`), then retry `list_pages`.
### Screenshot-write restrictions
The MCP may refuse to write into paths outside its configured workspace roots
(e.g. `.omo/evidence/screenshots/` under a worktree that canonicalizes to an unmapped
path). Save the screenshot to `/tmp` via the MCP, then copy it into the evidence dir:
```bash
# MCP: take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
# Shell:
mkdir -p <repo-root>/.omo/evidence/screenshots
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
```
### Time budgets & abort rule
See SKILL.md "Time Budgets & Abort Guidance": if a phase exceeds ~2x its budget or a
tool call retries 3+ times in a row, STOP and report BLOCKED with the last observed
state (server PID + `ss -tlnp`, page snapshot, last API response). Do not loop.
@@ -2,6 +2,14 @@
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features. This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
> **Run preconditions (from SKILL.md)**: every run uses the **sandboxed** standalone
> server on a free port `{PORT}` (default candidate `8188`, only if actually free — pick
> e.g. `8199` when `8188` is occupied by a live ComfyUI). Fixtures live in the sandboxed
> `recipes_path` as `f"{id}.recipe.json"` files with matching in-JSON `id`; the real user
> config and real library are never touched (record protection proof before/after).
> Abort if a phase exceeds ~2x its budget or a tool call retries 3+ times (SKILL.md
> "Time Budgets & Abort Guidance").
## Table of Contents ## Table of Contents
1. [LoRA List Page](#lora-list-page) 1. [LoRA List Page](#lora-list-page)
@@ -19,7 +27,7 @@ This document provides detailed test scenarios for end-to-end validation of LoRa
**Objective**: Verify the LoRA list page loads correctly and displays models. **Objective**: Verify the LoRA list page loads correctly and displays models.
**Steps**: **Steps**:
1. Navigate to `http://127.0.0.1:8188/loras` 1. Navigate to `http://127.0.0.1:{PORT}/loras`
2. Wait for page title "LoRAs" to appear 2. Wait for page title "LoRAs" to appear
3. Take snapshot to verify: 3. Take snapshot to verify:
- Header with "LoRAs" title is visible - Header with "LoRAs" title is visible
@@ -134,7 +142,7 @@ evaluate_script(function="""
**Objective**: Verify recipes page loads and displays recipes. **Objective**: Verify recipes page loads and displays recipes.
**Steps**: **Steps**:
1. Navigate to `http://127.0.0.1:8188/recipes` 1. Navigate to `http://127.0.0.1:{PORT}/recipes`
2. Wait for "Recipes" title 2. Wait for "Recipes" title
3. Take snapshot 3. Take snapshot
@@ -176,7 +184,7 @@ evaluate_script(function="""
**Objective**: Verify settings page displays correctly. **Objective**: Verify settings page displays correctly.
**Steps**: **Steps**:
1. Navigate to `http://127.0.0.1:8188/settings` 1. Navigate to `http://127.0.0.1:{PORT}/settings`
2. Wait for "Settings" title 2. Wait for "Settings" title
3. Take snapshot 3. Take snapshot
@@ -190,7 +198,7 @@ evaluate_script(function="""
1. Navigate to settings page 1. Navigate to settings page
2. Change a setting (e.g., default view mode) 2. Change a setting (e.g., default view mode)
3. Save settings 3. Save settings
4. Restart server: `python scripts/start_server.py --restart --wait` 4. Restart server: `python scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach`
5. Refresh browser page 5. Refresh browser page
6. Navigate to settings 6. Navigate to settings
@@ -8,186 +8,208 @@ This script shows how to:
3. Verify functionality end-to-end 3. Verify functionality end-to-end
Note: This is a template. Actual execution requires Chrome DevTools MCP. Note: This is a template. Actual execution requires Chrome DevTools MCP.
Port: pick a FREE port for the run — 8188 is commonly occupied by a live
ComfyUI (see the skill's Port Selection section). Set PORT below to e.g. 8199
when 8188 is taken. Always run against a SANDBOXED standalone server.
""" """
import subprocess import subprocess
import sys import sys
import time
# Choose the E2E port. 8188 is only the default candidate; use 8199 (or any
# free port checked with `ss -tlnp`) when 8188 is occupied by a live ComfyUI.
PORT = "8188"
def run_test(): def run_test():
"""Run example E2E test flow.""" """Run example E2E test flow."""
print("=" * 60) print("=" * 60)
print("LoRa Manager E2E Test Example") print("LoRa Manager E2E Test Example")
print("=" * 60) print("=" * 60)
# Step 1: Start server # Step 1: Start server (detached so it survives the shell)
print("\n[1/5] Starting LoRa Manager standalone server...") print("\n[1/5] Starting LoRa Manager standalone server...")
result = subprocess.run( result = subprocess.run(
[sys.executable, "start_server.py", "--port", "8188", "--wait", "--timeout", "30"], [sys.executable, "start_server.py", "--port", PORT, "--wait", "--timeout", "30", "--detach"],
capture_output=True, capture_output=True,
text=True text=True,
) )
if result.returncode != 0: if result.returncode != 0:
print(f"Failed to start server: {result.stderr}") print(f"Failed to start server: {result.stderr}")
return 1 return 1
print("Server ready!") print("Server ready!")
# Step 2: Open Chrome (manual step - show command) # Step 2: Open Chrome (manual step - show command)
print("\n[2/5] Open Chrome with debug mode:") print("\n[2/5] Open Chrome with debug mode:")
print("google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras") print(
f"google-chrome --remote-debugging-port=9222 "
f"--user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras"
)
print("(In actual test, this would be automated via MCP)") print("(In actual test, this would be automated via MCP)")
# Step 3: Navigate and verify page load # Step 3: Navigate and verify page load
print("\n[3/5] Page Load Verification:") print("\n[3/5] Page Load Verification:")
print(""" print(
f"""
MCP Commands to execute: MCP Commands to execute:
1. navigate_page(type="url", url="http://127.0.0.1:8188/loras") 1. navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. wait_for(text="LoRAs", timeout=10000) 2. wait_for(text="LoRAs", timeout=10000)
3. snapshot = take_snapshot() 3. snapshot = take_snapshot()
""") """
)
# Step 4: Test search functionality # Step 4: Test search functionality
print("\n[4/5] Search Functionality Test:") print("\n[4/5] Search Functionality Test:")
print(""" print(
"""
MCP Commands to execute: MCP Commands to execute:
1. fill(uid="search-input", value="test") 1. fill(uid="search-input", value="test")
2. press_key(key="Enter") 2. press_key(key="Enter")
3. wait_for(text="Results", timeout=5000) 3. wait_for(text="Results", timeout=5000)
4. result = evaluate_script(function=""" 4. result = evaluate_script(function=`
() => { () => {
const cards = document.querySelectorAll('.lora-card'); const cards = document.querySelectorAll('.lora-card');
return { count: cards.length }; return { count: cards.length };
} }
""") `)
""") """
)
# Step 5: Verify API # Step 5: Verify API
print("\n[5/5] API Verification:") print("\n[5/5] API Verification:")
print(""" print(
"""
MCP Commands to execute: MCP Commands to execute:
1. api_result = evaluate_script(function=""" 1. api_result = evaluate_script(function=`
async () => { async () => {
const response = await fetch('/loras/api/list'); const response = await fetch('/loras/api/list');
const data = await response.json(); const data = await response.json();
return { count: data.length, status: response.status }; return { count: data.length, status: response.status };
} }
""") `)
2. Verify api_result['status'] == 200 2. Verify api_result['status'] == 200
""") """
)
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("Test flow completed!") print("Test flow completed!")
print("=" * 60) print("=" * 60)
return 0 return 0
def example_restart_flow(): def example_restart_flow():
"""Example: Testing configuration change that requires restart.""" """Example: Testing configuration change that requires restart."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("Example: Server Restart Flow") print("Example: Server Restart Flow")
print("=" * 60) print("=" * 60)
print(""" print(
f"""
Scenario: Change setting and verify after restart Scenario: Change setting and verify after restart
Steps: Steps:
1. Navigate to settings page 1. Navigate to settings page
- navigate_page(type="url", url="http://127.0.0.1:8188/settings") - navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
2. Change a setting (e.g., theme) 2. Change a setting (e.g., theme)
- fill(uid="theme-select", value="dark") - fill(uid="theme-select", value="dark")
- click(uid="save-settings-button") - click(uid="save-settings-button")
3. Restart server 3. Restart server
- subprocess.run([python, "start_server.py", "--restart", "--wait"]) - subprocess.run([python, "start_server.py", "--port", "{PORT}", "--restart", "--wait", "--detach"])
4. Refresh browser 4. Refresh browser
- navigate_page(type="reload", ignoreCache=True) - navigate_page(type="reload", ignoreCache=True)
- wait_for(text="LoRAs", timeout=15000) - wait_for(text="LoRAs", timeout=15000)
5. Verify setting persisted 5. Verify setting persisted
- navigate_page(type="url", url="http://127.0.0.1:8188/settings") - navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value") - theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
- assert theme == "dark" - assert theme == "dark"
""") """
)
def example_modal_interaction(): def example_modal_interaction():
"""Example: Testing modal dialog interaction.""" """Example: Testing modal dialog interaction."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("Example: Modal Dialog Interaction") print("Example: Modal Dialog Interaction")
print("=" * 60) print("=" * 60)
print(""" print(
"""
Scenario: Add new LoRA via modal Scenario: Add new LoRA via modal
Steps: Steps:
1. Open modal 1. Open modal
- click(uid="add-lora-button") - click(uid="add-lora-button")
- wait_for(text="Add LoRA", timeout=3000) - wait_for(text="Add LoRA", timeout=3000)
2. Fill form 2. Fill form
- fill_form(elements=[ - fill_form(elements=[
{"uid": "lora-name", "value": "Test Character"}, {"uid": "lora-name", "value": "Test Character"},
{"uid": "lora-path", "value": "/models/test.safetensors"}, {"uid": "lora-path", "value": "/models/test.safetensors"},
]) ])
3. Submit 3. Submit
- click(uid="modal-submit-button") - click(uid="modal-submit-button")
4. Verify success 4. Verify success
- wait_for(text="Successfully added", timeout=5000) - wait_for(text="Successfully added", timeout=5000)
- snapshot = take_snapshot() - snapshot = take_snapshot()
""") """
)
def example_network_monitoring(): def example_network_monitoring():
"""Example: Network request monitoring.""" """Example: Network request monitoring."""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("Example: Network Request Monitoring") print("Example: Network Request Monitoring")
print("=" * 60) print("=" * 60)
print(""" print(
f"""
Scenario: Verify API calls during user interaction Scenario: Verify API calls during user interaction
Steps: Steps:
1. Clear network log (implicit on navigation) 1. Clear network log (implicit on navigation)
- navigate_page(type="url", url="http://127.0.0.1:8188/loras") - navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
2. Perform action that triggers API call 2. Perform action that triggers API call
- fill(uid="search-input", value="character") - fill(uid="search-input", value="character")
- press_key(key="Enter") - press_key(key="Enter")
3. List network requests 3. List network requests
- requests = list_network_requests(resourceTypes=["xhr", "fetch"]) - requests = list_network_requests(resourceTypes=["xhr", "fetch"])
4. Find search API call 4. Find search API call
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")] - search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
- assert len(search_requests) > 0, "Search API was not called" - assert len(search_requests) > 0, "Search API was not called"
5. Get request details 5. Get request details
- if search_requests: - if search_requests:
details = get_network_request(reqid=search_requests[0]["reqid"]) details = get_network_request(reqid=search_requests[0]["reqid"])
- Verify request method, response status, etc. - Verify request method, response status, etc.
""") """
)
if __name__ == "__main__": if __name__ == "__main__":
print("LoRa Manager E2E Test Examples\n") print("LoRa Manager E2E Test Examples\n")
print("This script demonstrates E2E testing patterns.\n") print("This script demonstrates E2E testing patterns.\n")
print("Note: Actual execution requires Chrome DevTools MCP connection.\n") print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
run_test() run_test()
example_restart_flow() example_restart_flow()
example_modal_interaction() example_modal_interaction()
example_network_monitoring() example_network_monitoring()
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("All examples shown!") print("All examples shown!")
print("=" * 60) print("=" * 60)
@@ -1,15 +1,78 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Start or restart LoRa Manager standalone server for E2E testing. Start or restart LoRa Manager standalone server for E2E testing.
Backward-compatible CLI: --port, --restart, --wait, --timeout all work as before.
New options: --detach (setsid-style fully detached launch, survives shell death).
Safety rules implemented here:
- Never kill processes the script did not start. The script tracks the PIDs it
manages in a pidfile (/tmp/lora-manager-e2e-server-{PORT}.pid).
- If the port is held by an unrelated process (e.g. a live ComfyUI) the script
reports the conflict and exits early instead of killing it.
- --restart only kills managed PIDs; if unrelated processes still hold the port
afterwards, the script reports them and aborts.
""" """
from __future__ import annotations
import argparse import argparse
import os
import signal
import socket
import subprocess import subprocess
import sys import sys
import time import time
import socket
import signal PIDFILE_PREFIX = "/tmp/lora-manager-e2e-server"
import os
def pidfile_path(port: int) -> str:
"""Path of the pidfile that records PIDs this script started for a port."""
return f"{PIDFILE_PREFIX}-{port}.pid"
def read_managed_pids(port: int) -> list[int]:
"""Read PIDs this script previously managed for the port (may be stale)."""
path = pidfile_path(port)
if not os.path.exists(path):
return []
try:
with open(path, "r", encoding="utf-8") as fh:
return [int(line.strip()) for line in fh if line.strip().isdigit()]
except (OSError, ValueError):
return []
def write_managed_pids(port: int, pids: list[int]) -> None:
"""Record PIDs this script manages for the port."""
try:
with open(pidfile_path(port), "w", encoding="utf-8") as fh:
for pid in pids:
fh.write(f"{pid}\n")
except OSError as exc:
print(f"Warning: could not write pidfile for port {port}: {exc}")
def clear_managed_pids(port: int) -> None:
"""Remove the pidfile for the port (no longer managed)."""
path = pidfile_path(port)
try:
if os.path.exists(path):
os.remove(path)
except OSError as exc:
print(f"Warning: could not remove pidfile {path}: {exc}")
def process_alive(pid: int) -> bool:
"""Return True if a process with the given pid exists."""
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True # exists but owned by someone else
def find_server_process(port: int) -> list[int]: def find_server_process(port: int) -> list[int]:
@@ -19,7 +82,7 @@ def find_server_process(port: int) -> list[int]:
["lsof", "-ti", f":{port}"], ["lsof", "-ti", f":{port}"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False,
) )
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0 and result.stdout.strip():
return [int(pid) for pid in result.stdout.strip().split("\n") if pid] return [int(pid) for pid in result.stdout.strip().split("\n") if pid]
@@ -30,7 +93,7 @@ def find_server_process(port: int) -> list[int]:
["netstat", "-tlnp"], ["netstat", "-tlnp"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False,
) )
pids = [] pids = []
for line in result.stdout.split("\n"): for line in result.stdout.split("\n"):
@@ -49,30 +112,48 @@ def find_server_process(port: int) -> list[int]:
return [] return []
def kill_server(port: int) -> None: def describe_processes(pids: list[int]) -> str:
"""Kill processes using the specified port.""" """Human-readable description of a pid list (pid + command line)."""
pids = find_server_process(port) descriptions = []
for pid in pids: for pid in pids:
cmdline = ""
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
raw = fh.read().replace(b"\x00", b" ").decode("utf-8", "replace")
cmdline = raw.strip()
except OSError:
pass
descriptions.append(f"pid {pid}{' (' + cmdline + ')' if cmdline else ''}")
return ", ".join(descriptions) if descriptions else "none"
def kill_pids(pids: list[int], what: str) -> None:
"""Send SIGTERM (then SIGKILL) to the given PIDs, only after reporting."""
for pid in pids:
print(f"Sent SIGTERM to {what} pid {pid}")
try: try:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
print(f"Sent SIGTERM to process {pid}")
except ProcessLookupError: except ProcessLookupError:
pass pass
# Wait for processes to terminate # Wait for processes to terminate
time.sleep(1) deadline = time.time() + 5
while time.time() < deadline:
if not any(process_alive(pid) for pid in pids):
break
time.sleep(0.2)
# Force kill if still running # Force kill if still running
pids = find_server_process(port)
for pid in pids: for pid in pids:
try: if process_alive(pid):
os.kill(pid, signal.SIGKILL) try:
print(f"Sent SIGKILL to process {pid}") os.kill(pid, signal.SIGKILL)
except ProcessLookupError: print(f"Sent SIGKILL to {what} pid {pid}")
pass except ProcessLookupError:
pass
def is_server_ready(port: int, timeout: float = 0.5) -> bool: def is_server_ready(port: int, timeout: float = 2.0) -> bool:
"""Check if server is accepting connections.""" """Check if server is accepting connections."""
try: try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout): with socket.create_connection(("127.0.0.1", port), timeout=timeout):
@@ -84,9 +165,15 @@ def is_server_ready(port: int, timeout: float = 0.5) -> bool:
def wait_for_server(port: int, timeout: int = 30) -> bool: def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready.""" """Wait for server to become ready."""
start = time.time() start = time.time()
last_report = 0.0
while time.time() - start < timeout: while time.time() - start < timeout:
if is_server_ready(port): if is_server_ready(port):
return True return True
# Report progress every ~5s so a slow boot is visible, not silent.
elapsed = time.time() - start
if elapsed - last_report >= 5:
print(f" ...still waiting ({int(elapsed)}s/{timeout}s)")
last_report = elapsed
time.sleep(0.5) time.sleep(0.5)
return False return False
@@ -99,68 +186,148 @@ def main() -> int:
"--port", "--port",
type=int, type=int,
default=8188, default=8188,
help="Server port (default: 8188)" help="Server port (default: 8188)",
) )
parser.add_argument( parser.add_argument(
"--restart", "--restart",
action="store_true", action="store_true",
help="Kill existing server before starting" help="Kill the E2E server previously managed by this script for the port "
"(tracked via pidfile) before starting; refuse to kill unrelated processes",
) )
parser.add_argument( parser.add_argument(
"--wait", "--wait",
action="store_true", action="store_true",
help="Wait for server to be ready before exiting" help="Wait for server to be ready before exiting",
) )
parser.add_argument( parser.add_argument(
"--timeout", "--timeout",
type=int, type=int,
default=30, default=30,
help="Timeout for waiting (default: 30)" help="Timeout for waiting (default: 30)",
) )
parser.add_argument(
"--detach",
action="store_true",
help="Launch the server fully detached (setsid-style) so it survives shell "
"death. REQUIRED for E2E: a plain background process dies with the shell",
)
args = parser.parse_args() args = parser.parse_args()
# Get project root (parent of .agents directory) # Get project root (parent of .agents directory)
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
skill_dir = os.path.dirname(script_dir) skill_dir = os.path.dirname(script_dir)
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir))) project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
# Restart if requested managed_pids = read_managed_pids(args.port)
# Restart if requested: kill ONLY managed PIDs.
if args.restart: if args.restart:
print(f"Killing existing server on port {args.port}...") alive_managed = [pid for pid in managed_pids if process_alive(pid)]
kill_server(args.port) if alive_managed:
print(
f"Killing E2E server previously started by this script on port "
f"{args.port} ({describe_processes(alive_managed)})..."
)
kill_pids(alive_managed, "managed E2E server")
else:
print(
f"No live managed E2E server for port {args.port} "
f"(pidfile: {pidfile_path(args.port)})"
)
time.sleep(1) time.sleep(1)
# Refuse to kill anything the script did not manage.
# Check if already running remaining = find_server_process(args.port)
if is_server_ready(args.port): if remaining:
print(f"Server already running on port {args.port}") print(
return 0 f"ERROR: port {args.port} is still held by process(es) this script "
f"did not start: {describe_processes(remaining)}."
)
print(
"These may be unrelated (e.g. a live ComfyUI). The script will NOT "
"kill them. Pick a different --port, or stop them manually if you "
"are certain they are stale E2E servers."
)
return 2
clear_managed_pids(args.port)
# Port conflict check before starting: never blind-kill.
port_pids = find_server_process(args.port)
if port_pids:
alive_managed = [pid for pid in port_pids if pid in managed_pids]
unmanaged = [pid for pid in port_pids if pid not in managed_pids]
if alive_managed and not unmanaged:
print(
f"Server already running on port {args.port} "
f"({describe_processes(alive_managed)}, started by this script). "
f"Use --restart to recycle it."
)
return 0
print(
f"ERROR: port {args.port} is already in use by process(es): "
f"{describe_processes(port_pids)}."
)
print(
"This is likely an unrelated process (e.g. a live ComfyUI holding 8188). "
"The script will NOT kill it. Pick a free port with --port, e.g. 8199."
)
return 2
# Start server # Start server
print(f"Starting LoRa Manager standalone server on port {args.port}...") print(f"Starting LoRa Manager standalone server on port {args.port}...")
cmd = [sys.executable, "standalone.py", "--port", str(args.port)] cmd = [
sys.executable,
# Start in background "standalone.py",
process = subprocess.Popen( "--host",
cmd, "127.0.0.1",
cwd=project_root, "--port",
stdout=subprocess.PIPE, str(args.port),
stderr=subprocess.PIPE, ]
start_new_session=True
) if args.detach:
# Fully detached launch: new session (setsid), no controlling terminal,
print(f"Server process started with PID {process.pid}") # stdin from /dev/null, stdout/stderr to a log file. Survives the shell.
log_dir = os.path.join(script_dir, "logs")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, f"server-{args.port}.log")
with open(log_path, "ab") as log_fh:
process = subprocess.Popen(
cmd,
cwd=project_root,
stdin=subprocess.DEVNULL,
stdout=log_fh,
stderr=subprocess.STDOUT,
start_new_session=True,
close_fds=True,
)
print(f"Detached server process started with PID {process.pid} (setsid)")
print(f"Log: {log_path}")
else:
# Plain background process (legacy behavior): dies with the shell.
process = subprocess.Popen(
cmd,
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
print(f"Server process started with PID {process.pid}")
print(
"NOTE: not detached — this process dies when the launching shell exits. "
"For E2E use --detach."
)
write_managed_pids(args.port, [process.pid])
# Wait for ready if requested # Wait for ready if requested
if args.wait: if args.wait:
print(f"Waiting for server to be ready (timeout: {args.timeout}s)...") print(f"Waiting for server to be ready (timeout: {args.timeout}s)...")
if wait_for_server(args.port, args.timeout): if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras") print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0 return 0
else: print(f"Timeout waiting for server on port {args.port}")
print(f"Timeout waiting for server") return 1
return 1
print(f"Server starting at http://127.0.0.1:{args.port}/loras") print(f"Server starting at http://127.0.0.1:{args.port}/loras")
return 0 return 0
@@ -1,15 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Wait for LoRa Manager server to become ready. Wait for LoRa Manager server to become ready.
Timeout is configurable via --timeout (default 30s); the script polls the port
until the server accepts connections or the timeout expires.
""" """
from __future__ import annotations
import argparse import argparse
import socket import socket
import sys import sys
import time import time
def is_server_ready(port: int, timeout: float = 0.5) -> bool: def is_server_ready(port: int, timeout: float = 2.0) -> bool:
"""Check if server is accepting connections.""" """Check if server is accepting connections."""
try: try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout): with socket.create_connection(("127.0.0.1", port), timeout=timeout):
@@ -21,9 +26,15 @@ def is_server_ready(port: int, timeout: float = 0.5) -> bool:
def wait_for_server(port: int, timeout: int = 30) -> bool: def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready.""" """Wait for server to become ready."""
start = time.time() start = time.time()
last_report = 0.0
while time.time() - start < timeout: while time.time() - start < timeout:
if is_server_ready(port): if is_server_ready(port):
return True return True
# Report progress every ~5s so a slow boot is visible, not silent.
elapsed = time.time() - start
if elapsed - last_report >= 5:
print(f" ...still waiting ({int(elapsed)}s/{timeout}s)")
last_report = elapsed
time.sleep(0.5) time.sleep(0.5)
return False return False
@@ -36,25 +47,24 @@ def main() -> int:
"--port", "--port",
type=int, type=int,
default=8188, default=8188,
help="Server port (default: 8188)" help="Server port (default: 8188)",
) )
parser.add_argument( parser.add_argument(
"--timeout", "--timeout",
type=int, type=int,
default=30, default=30,
help="Timeout in seconds (default: 30)" help="Timeout in seconds (default: 30)",
) )
args = parser.parse_args() args = parser.parse_args()
print(f"Waiting for server on port {args.port} (timeout: {args.timeout}s)...") print(f"Waiting for server on port {args.port} (timeout: {args.timeout}s)...")
if wait_for_server(args.port, args.timeout): if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras") print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0 return 0
else: print(f"Timeout: Server not ready after {args.timeout}s")
print(f"Timeout: Server not ready after {args.timeout}s") return 1
return 1
if __name__ == "__main__": if __name__ == "__main__":
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}" "error": "Recipe-Reparatur fehlgeschlagen: {message}"
}, },
"rematchRecipes": {
"label": "Rezepte lokalen Modellen neu zuordnen",
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten" "label": "Ausgeschlossene Modelle verwalten"
}, },
@@ -768,6 +778,7 @@
"copyAll": "Alle Syntax kopieren", "copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren", "refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren", "repairMetadata": "Metadaten der Auswahl reparieren",
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren", "reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen", "checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben", "moveAll": "Alle in Ordner verschieben",
@@ -823,6 +834,7 @@
"setContentRating": "Inhaltsbewertung festlegen", "setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren", "repairMetadata": "Metadaten reparieren",
"rematchMetadata": "Mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren", "reimportMetadata": "Aus Quelle neu importieren",
"excludeModel": "Modell ausschließen", "excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen", "restoreModel": "Modell wiederherstellen",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})", "repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich", "repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}", "repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
"reimporting": "Rezept wird aus Quelle neu importiert...", "reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert", "reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "Repair cancelled. {count} recipes were repaired.", "cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}" "error": "Recipe repair failed: {message}"
}, },
"rematchRecipes": {
"label": "Rematch recipes to local models",
"loading": "Rematching recipes to local models...",
"success": "Matched {entries} entries across {recipes} recipes",
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"allFailed": "Rematch failed for {failures} of {total} recipes",
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
"error": "Recipe rematch failed: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Manage Excluded Models" "label": "Manage Excluded Models"
}, },
@@ -768,6 +778,7 @@
"copyAll": "Copy Selected Syntax", "copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata", "refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected", "repairMetadata": "Repair Metadata for Selected",
"rematchMetadata": "Rematch Selected to Local Models",
"reimportMetadata": "Re-import from Source", "reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected", "checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder", "moveAll": "Move Selected to Folder",
@@ -823,6 +834,7 @@
"setContentRating": "Set Content Rating", "setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata", "repairMetadata": "Repair metadata",
"rematchMetadata": "Rematch to local models",
"reimportMetadata": "Re-import from Source", "reimportMetadata": "Re-import from Source",
"excludeModel": "Exclude Model", "excludeModel": "Exclude Model",
"restoreModel": "Restore Model", "restoreModel": "Restore Model",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})", "repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes", "repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}", "repairBulkFailed": "Failed to repair selected recipes: {message}",
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...", "reimporting": "Re-importing recipe from source...",
"reimportSuccess": "Recipe re-imported successfully", "reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}" "error": "Error al reparar recetas: {message}"
}, },
"rematchRecipes": {
"label": "Reasociar recetas con modelos locales",
"loading": "Reasociando recetas con modelos locales...",
"success": "{entries} entradas asociadas en {recipes} recetas",
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
"error": "Falló la reasociación de recetas: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gestionar modelos excluidos" "label": "Gestionar modelos excluidos"
}, },
@@ -768,6 +778,7 @@
"copyAll": "Copiar toda la sintaxis", "copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos", "refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección", "repairMetadata": "Reparar metadatos de la selección",
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
"reimportMetadata": "Reimportar desde origen", "reimportMetadata": "Reimportar desde origen",
"checkUpdates": "Comprobar actualizaciones para la selección", "checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta", "moveAll": "Mover todos a carpeta",
@@ -823,6 +834,7 @@
"setContentRating": "Establecer clasificación de contenido", "setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos", "repairMetadata": "Reparar metadatos",
"rematchMetadata": "Reasociar con modelos locales",
"reimportMetadata": "Reimportar desde origen", "reimportMetadata": "Reimportar desde origen",
"excludeModel": "Excluir modelo", "excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo", "restoreModel": "Restaurar modelo",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})", "repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas", "repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}", "repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
"reimporting": "Reimportando receta desde origen...", "reimporting": "Reimportando receta desde origen...",
"reimportSuccess": "Receta reimportada exitosamente", "reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "Réparation annulée. {count} recettes ont été réparées.", "cancelled": "Réparation annulée. {count} recettes ont été réparées.",
"error": "Échec de la réparation des recettes : {message}" "error": "Échec de la réparation des recettes : {message}"
}, },
"rematchRecipes": {
"label": "Réassocier les recettes aux modèles locaux",
"loading": "Réassociation des recettes aux modèles locaux...",
"success": "{entries} entrées associées dans {recipes} recettes",
"successErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
"allFailed": "Échec de la réassociation de {failures} recettes sur {total}",
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes",
"cancelled": "Réassociation annulée. {recipes} recettes mises à jour ({entries} entrées)",
"error": "Échec de la réassociation des recettes : {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Gérer les modèles exclus" "label": "Gérer les modèles exclus"
}, },
@@ -768,6 +778,7 @@
"copyAll": "Copier toute la syntaxe", "copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées", "refreshAll": "Actualiser toutes les métadonnées",
"repairMetadata": "Réparer les métadonnées de la sélection", "repairMetadata": "Réparer les métadonnées de la sélection",
"rematchMetadata": "Réassocier la sélection aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source", "reimportMetadata": "Ré-importer depuis la source",
"checkUpdates": "Vérifier les mises à jour pour la sélection", "checkUpdates": "Vérifier les mises à jour pour la sélection",
"moveAll": "Déplacer tout vers un dossier", "moveAll": "Déplacer tout vers un dossier",
@@ -823,6 +834,7 @@
"setContentRating": "Définir la classification du contenu", "setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées", "repairMetadata": "Réparer les métadonnées",
"rematchMetadata": "Réassocier aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source", "reimportMetadata": "Ré-importer depuis la source",
"excludeModel": "Exclure le modèle", "excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle", "restoreModel": "Restaurer le modèle",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})", "repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées", "repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} recettes sélectionnées",
"repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}", "repairBulkFailed": "Échec de la réparation des recettes sélectionnées : {message}",
"rematchComplete": "{entries} entrées associées dans {recipes} recettes",
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} recettes, {failures} échecs",
"rematchAllFailed": "Échec de la réassociation de {failures} recettes sélectionnées sur {total}",
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} recettes",
"rematchSkipped": "Aucune des {total} recettes sélectionnées ne nécessite de réassociation",
"rematchFailed": "Échec de la réassociation des recettes sélectionnées : {message}",
"reimporting": "Ré-import de la recette depuis la source...", "reimporting": "Ré-import de la recette depuis la source...",
"reimportSuccess": "Recette ré-importée avec succès", "reimportSuccess": "Recette ré-importée avec succès",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}" "error": "תיקון המתכונים נכשל: {message}"
}, },
"rematchRecipes": {
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "ניהול מודלים מוחרגים" "label": "ניהול מודלים מוחרגים"
}, },
@@ -768,6 +778,7 @@
"copyAll": "העתק את כל התחבירים", "copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה", "refreshAll": "רענן את כל המטא-דאטה",
"repairMetadata": "תקן מטא-דאטה עבור הנבחרים", "repairMetadata": "תקן מטא-דאטה עבור הנבחרים",
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור", "reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה", "moveAll": "העבר הכל לתיקייה",
@@ -823,6 +834,7 @@
"setContentRating": "הגדר דירוג תוכן", "setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה", "repairMetadata": "תיקון מטא-דאטה",
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור", "reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל", "restoreModel": "שחזור מודל",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})", "repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים", "repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}", "repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...", "reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה", "reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}" "error": "レシピの修復に失敗しました: {message}"
}, },
"rematchRecipes": {
"label": "レシピをローカルモデルに再マッチング",
"loading": "レシピをローカルモデルに再マッチングしています...",
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
"error": "レシピの再マッチングに失敗しました:{message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "除外モデルを管理" "label": "除外モデルを管理"
}, },
@@ -768,6 +778,7 @@
"copyAll": "すべての構文をコピー", "copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新", "refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復", "repairMetadata": "選択したレシピのメタデータを修復",
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート", "reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動", "moveAll": "すべてをフォルダに移動",
@@ -823,6 +834,7 @@
"setContentRating": "コンテンツレーティングを設定", "setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復", "repairMetadata": "メタデータを修復",
"rematchMetadata": "ローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート", "reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
"restoreModel": "モデルを復元", "restoreModel": "モデルを復元",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)", "repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です", "repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}", "repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...", "reimporting": "ソースからレシピを再インポート中...",
"reimportSuccess": "レシピの再インポートが完了しました", "reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}" "error": "레시피 복구 실패: {message}"
}, },
"rematchRecipes": {
"label": "레시피를 로컬 모델에 다시 매칭",
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
"error": "레시피 재매칭 실패: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "제외된 모델 관리" "label": "제외된 모델 관리"
}, },
@@ -768,6 +778,7 @@
"copyAll": "모든 문법 복사", "copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침", "refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구", "repairMetadata": "선택한 레시피 메타데이터 복구",
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기", "reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동", "moveAll": "모두 폴더로 이동",
@@ -823,6 +834,7 @@
"setContentRating": "콘텐츠 등급 설정", "setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구", "repairMetadata": "메타데이터 복구",
"rematchMetadata": "로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기", "reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
"restoreModel": "모델 복원", "restoreModel": "모델 복원",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)", "repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다", "repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}", "repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...", "reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportSuccess": "레시피를 다시 가져왔습니다", "reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}" "error": "Ошибка восстановления рецептов: {message}"
}, },
"rematchRecipes": {
"label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "Повторное сопоставление рецептов с локальными моделями...",
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
"error": "Не удалось выполнить сопоставление рецептов: {message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "Управление исключёнными моделями" "label": "Управление исключёнными моделями"
}, },
@@ -768,6 +778,7 @@
"copyAll": "Копировать весь синтаксис", "copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные", "refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных", "repairMetadata": "Восстановить метаданные для выбранных",
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
"reimportMetadata": "Переимпортировать из источника", "reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку", "moveAll": "Переместить все в папку",
@@ -823,6 +834,7 @@
"setContentRating": "Установить рейтинг контента", "setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные", "repairMetadata": "Восстановить метаданные",
"rematchMetadata": "Сопоставить с локальными моделями",
"reimportMetadata": "Переимпортировать из источника", "reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель", "restoreModel": "Восстановить модель",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})", "repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления", "repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}", "repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...", "reimporting": "Переимпорт рецепта из источника...",
"reimportSuccess": "Рецепт успешно переимпортирован", "reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "修复已取消。已修复 {count} 个配方。", "cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}" "error": "配方修复失败:{message}"
}, },
"rematchRecipes": {
"label": "将食谱重新匹配到本地模型",
"loading": "正在将食谱重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
"allFailed": "{failures}/{total} 个食谱重新匹配失败",
"noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。",
"error": "食谱重新匹配失败:{message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
}, },
@@ -768,6 +778,7 @@
"copyAll": "复制所选中语法", "copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据", "refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据", "repairMetadata": "修复所选中元数据",
"rematchMetadata": "将所选中重新匹配到本地模型",
"reimportMetadata": "从源重新导入", "reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹", "moveAll": "移动所选中到文件夹",
@@ -823,6 +834,7 @@
"setContentRating": "设置内容评级", "setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据", "repairMetadata": "修复元数据",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "从源重新导入", "reimportMetadata": "从源重新导入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "恢复模型", "restoreModel": "恢复模型",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)", "repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复", "repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}", "repairBulkFailed": "修复所选配方失败:{message}",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败",
"rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选食谱均无需重新匹配",
"rematchFailed": "重新匹配所选食谱失败:{message}",
"reimporting": "正在从源重新导入配方...", "reimporting": "正在从源重新导入配方...",
"reimportSuccess": "配方已从源重新导入成功", "reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
+18
View File
@@ -186,6 +186,16 @@
"cancelled": "修復已取消。已修復 {count} 個配方。", "cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}" "error": "配方修復失敗:{message}"
}, },
"rematchRecipes": {
"label": "將食譜重新匹配到本地模型",
"loading": "正在將食譜重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
"allFailed": "{failures}/{total} 個食譜重新匹配失敗",
"noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。",
"error": "食譜重新匹配失敗:{message}"
},
"manageExcludedModels": { "manageExcludedModels": {
"label": "管理已排除的模型" "label": "管理已排除的模型"
}, },
@@ -768,6 +778,7 @@
"copyAll": "複製全部語法", "copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata", "refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據", "repairMetadata": "修復所選中元數據",
"rematchMetadata": "將所選中重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入", "reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾", "moveAll": "全部移動到資料夾",
@@ -823,6 +834,7 @@
"setContentRating": "設定內容分級", "setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據", "repairMetadata": "修復元數據",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入", "reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"restoreModel": "還原模型", "restoreModel": "還原模型",
@@ -1951,6 +1963,12 @@
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)", "repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復", "repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}", "repairBulkFailed": "修復所選配方失敗:{message}",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選食譜均無需重新匹配",
"rematchFailed": "重新匹配所選食譜失敗:{message}",
"reimporting": "正在從來源重新匯入配方...", "reimporting": "正在從來源重新匯入配方...",
"reimportSuccess": "配方已從來源重新匯入成功", "reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
+158
View File
@@ -112,6 +112,11 @@ class RecipeHandlerSet:
"repair_recipe": self.management.repair_recipe, "repair_recipe": self.management.repair_recipe,
"repair_recipes_bulk": self.management.repair_recipes_bulk, "repair_recipes_bulk": self.management.repair_recipes_bulk,
"get_repair_progress": self.management.get_repair_progress, "get_repair_progress": self.management.get_repair_progress,
"rematch_recipes": self.management.rematch_recipes,
"cancel_rematch": self.management.cancel_rematch,
"rematch_recipe": self.management.rematch_recipe,
"rematch_recipes_bulk": self.management.rematch_recipes_bulk,
"get_rematch_progress": self.management.get_rematch_progress,
"start_batch_import": self.batch_import.start_batch_import, "start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress, "get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import, "cancel_batch_import": self.batch_import.cancel_batch_import,
@@ -887,6 +892,159 @@ class RecipeManagementHandler:
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True) self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rematch_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Mutual exclusion: a global rematch cannot start while a rematch
# OR a repair is already running — both mutate recipes under the
# same mutation lock.
if (
self._ws_manager.is_recipe_rematch_running()
or self._ws_manager.is_recipe_repair_running()
):
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
recipe_scanner.reset_cancellation()
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data)
# Run in background to avoid timeout
async def run_rematch():
try:
await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback
)
except Exception as e:
self._logger.error(
f"Error in recipe rematch task: {e}", exc_info=True
)
await self._ws_manager.broadcast_recipe_rematch_progress(
{"status": "error", "error": str(e)}
)
finally:
# Keep the final status for a while so the UI can see it
await asyncio.sleep(5)
self._ws_manager.cleanup_recipe_rematch_progress()
asyncio.create_task(run_rematch())
return web.json_response(
{"success": True, "message": "Recipe rematch started"}
)
except Exception as exc:
self._logger.error("Error starting recipe rematch: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_rematch(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_scanner.cancel_task()
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling recipe rematch: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def rematch_recipes_bulk(self, request: web.Request) -> web.Response:
"""Rematch deleted resources for multiple recipes by their IDs.
Accepts a JSON body with a "recipe_ids" array. The per-recipe loop is
delegated to the scanner's rematch_recipes_bulk; this handler only
parses the request and returns the scanner's summary.
"""
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# A bulk rematch must not queue behind a running global rematch's
# mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
data = await request.json()
recipe_ids = data.get("recipe_ids", [])
if not recipe_ids:
return web.json_response(
{"success": False, "error": "recipe_ids are required"},
status=400,
)
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
return web.json_response(result)
except Exception as exc:
self._logger.error(
"Error performing bulk rematch: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def rematch_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response(
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Reject per-recipe rematches while a global run is in progress so
# they do not queue behind the mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error rematching single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_rematch_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_rematch_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response(
{"success": False, "message": "No rematch in progress"}, status=404
)
except Exception as exc:
self._logger.error("Error getting rematch progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reimport_recipe(self, request: web.Request) -> web.Response: async def reimport_recipe(self, request: web.Request) -> web.Response:
"""Delete a recipe and re-import it from its source URL. """Delete a recipe and re-import it from its source URL.
+5
View File
@@ -61,6 +61,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"), RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
RouteDefinition("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
RouteDefinition("POST", "/api/lm/recipes/cancel-rematch", "cancel_rematch"),
RouteDefinition("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"), RouteDefinition("POST", "/api/lm/recipes/batch-import/start", "start_batch_import"),
RouteDefinition( RouteDefinition(
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress" "GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
+72 -14
View File
@@ -24,6 +24,10 @@ from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Maximum times the download poll loop will re-schedule a transfer after it
# is lost (daemon restart / RPC outage) before failing the download.
MAX_TRANSFER_RECOVERY_ATTEMPTS = 2
def _try_certifi_ca_path() -> str | None: def _try_certifi_ca_path() -> str | None:
"""Return the certifi CA bundle path if available, else None.""" """Return the certifi CA bundle path if available, else None."""
try: try:
@@ -85,6 +89,7 @@ class Aria2Downloader:
self._rpc_session: Optional[aiohttp.ClientSession] = None self._rpc_session: Optional[aiohttp.ClientSession] = None
self._rpc_session_lock = asyncio.Lock() self._rpc_session_lock = asyncio.Lock()
self._process_lock = asyncio.Lock() self._process_lock = asyncio.Lock()
self._register_lock = asyncio.Lock()
self._transfers: Dict[str, Aria2Transfer] = {} self._transfers: Dict[str, Aria2Transfer] = {}
self._poll_interval = 0.5 self._poll_interval = 0.5
self._state_store = Aria2TransferStateStore() self._state_store = Aria2TransferStateStore()
@@ -103,26 +108,58 @@ class Aria2Downloader:
progress_callback=None, progress_callback=None,
headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None,
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""Download a file using aria2 RPC and wait for completion.""" """Download a file using aria2 RPC and wait for completion.
The poll loop is self-healing: when the in-memory transfer entry
disappears (e.g. another download restarted the daemon and
``close()`` cleared ``_transfers``) or the RPC becomes unreachable,
the transfer is re-scheduled with ``continue=true`` so the download
resumes from the on-disk ``.aria2`` control file. Recovery is bounded
by ``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
"""
await self._ensure_process() await self._ensure_process()
save_path = os.path.abspath(save_path) save_path = os.path.abspath(save_path)
transfer = self._transfers.get(download_id)
if transfer is None or os.path.abspath(transfer.save_path) != save_path:
gid = await self._schedule_download(
url,
save_path,
download_id=download_id,
headers=headers,
)
transfer = Aria2Transfer(gid=gid, save_path=save_path)
self._transfers[download_id] = transfer
async with self._register_lock:
transfer = self._transfers.get(download_id)
if transfer is None or os.path.abspath(transfer.save_path) != save_path:
transfer = await self._register_transfer(
url,
save_path,
download_id=download_id,
headers=headers,
)
recovery_attempts = 0
try: try:
while True: while True:
status = await self._get_status_with_retry(download_id) try:
status = await self._get_status_with_retry(download_id)
except Aria2Error:
status = None
if status is None: if status is None:
return False, "aria2 download not found" if recovery_attempts >= MAX_TRANSFER_RECOVERY_ATTEMPTS:
return False, "aria2 download not found"
recovery_attempts += 1
logger.warning(
"aria2 transfer %s lost; re-scheduling with resume "
"(attempt %d/%d)",
download_id,
recovery_attempts,
MAX_TRANSFER_RECOVERY_ATTEMPTS,
)
await asyncio.sleep(1.0)
await self._ensure_process()
async with self._register_lock:
transfer = await self._register_transfer(
url,
save_path,
download_id=download_id,
headers=headers,
)
continue
snapshot = self._build_progress_snapshot(status) snapshot = self._build_progress_snapshot(status)
if progress_callback is not None: if progress_callback is not None:
@@ -139,7 +176,9 @@ class Aria2Downloader:
await asyncio.sleep(self._poll_interval) await asyncio.sleep(self._poll_interval)
finally: finally:
self._transfers.pop(download_id, None) current = self._transfers.get(download_id)
if current is not None and current.gid == transfer.gid:
self._transfers.pop(download_id, None)
async def _get_status_with_retry( async def _get_status_with_retry(
self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0 self, download_id: str, *, max_retries: int = 4, retry_delay: float = 3.0
@@ -242,6 +281,25 @@ class Aria2Downloader:
) )
return gid return gid
async def _register_transfer(
self,
url: str,
save_path: str,
*,
download_id: str,
headers: Optional[Dict[str, str]] = None,
) -> Aria2Transfer:
"""Schedule a download and track it in the in-memory transfer registry."""
gid = await self._schedule_download(
url,
save_path,
download_id=download_id,
headers=headers,
)
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
self._transfers[download_id] = transfer
return transfer
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]: async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
"""Return the raw aria2 status payload for a known download.""" """Return the raw aria2 status payload for a known download."""
+735 -1
View File
@@ -11,8 +11,10 @@ import os
import time import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config 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 .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from natsort import natsorted from natsort import natsorted
import sys import sys
import re import re
@@ -26,6 +28,12 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) 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: class RecipeScanner:
"""Service for scanning and managing recipe images""" """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: dict[str, dict[str, Any]] | None = None
self._local_hash_cache_versions: tuple[int, int] | None = None self._local_hash_cache_versions: tuple[int, int] | None = None
self._local_hash_cache_lock = asyncio.Lock() 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 self._initialized = True
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]: async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
@@ -145,6 +160,166 @@ class RecipeScanner:
self._local_hash_cache_versions = versions self._local_hash_cache_versions = versions
return cache 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: def on_library_changed(self) -> None:
"""Reset cached state when the active library changes.""" """Reset cached state when the active library changes."""
@@ -411,6 +586,565 @@ class RecipeScanner:
return False 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: async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
"""Helper to save a recipe to both JSON and EXIF metadata.""" """Helper to save a recipe to both JSON and EXIF metadata."""
recipe_id = recipe.get("id") recipe_id = recipe.get("id")
+9
View File
@@ -20,3 +20,12 @@ class RecipeDownloadError(RecipeServiceError):
class RecipeConflictError(RecipeServiceError): class RecipeConflictError(RecipeServiceError):
"""Raised when a conflicting recipe state is detected.""" """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.
"""
+26
View File
@@ -22,6 +22,8 @@ class WebSocketManager:
self._auto_organize_progress: Optional[Dict[str, Any]] = None self._auto_organize_progress: Optional[Dict[str, Any]] = None
# Add recipe repair progress tracking # Add recipe repair progress tracking
self._recipe_repair_progress: Optional[Dict[str, Any]] = None 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() self._auto_organize_lock = asyncio.Lock()
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse: async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
@@ -223,6 +225,30 @@ class WebSocketManager:
status = self._recipe_repair_progress.get('status') status = self._recipe_repair_progress.get('status')
return status in ['started', 'processing'] 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: def is_auto_organize_running(self) -> bool:
"""Check if auto-organize is currently running""" """Check if auto-organize is currently running"""
if not self._auto_organize_progress: if not self._auto_organize_progress:
+34
View File
@@ -16,6 +16,8 @@ const RECIPE_ENDPOINTS = {
moveBulk: '/api/lm/recipes/move-bulk', moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete', bulkDelete: '/api/lm/recipes/bulk-delete',
repairBulk: '/api/lm/recipes/repair-bulk', repairBulk: '/api/lm/recipes/repair-bulk',
rematchBulk: '/api/lm/recipes/rematch-bulk',
rematchSingle: '/api/lm/recipe/{recipe_id}/rematch',
}; };
const RECIPE_SIDEBAR_CONFIG = { const RECIPE_SIDEBAR_CONFIG = {
@@ -586,6 +588,38 @@ export class RecipeSidebarApiClient {
return result; 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) { async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) { if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided'); 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 downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]'); const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
const reimportMetadataItem = this.menu.querySelector('[data-action="reimport-metadata"]'); const reimportMetadataItem = this.menu.querySelector('[data-action="reimport-metadata"]');
const rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]');
if (repairMetadataItem) { if (repairMetadataItem) {
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none'; repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
@@ -50,6 +51,9 @@ export class BulkContextMenu extends BaseContextMenu {
if (reimportMetadataItem) { if (reimportMetadataItem) {
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none'; reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
} }
if (rematchMetadataItem) {
rematchMetadataItem.style.display = config.rematchMetadata ? 'flex' : 'none';
}
const isEmbeddings = currentModelType === 'embeddings'; const isEmbeddings = currentModelType === 'embeddings';
if (sendToWorkflowAppendItem) { if (sendToWorkflowAppendItem) {
@@ -282,6 +286,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'repair-metadata': case 'repair-metadata':
bulkManager.repairSelectedRecipes(); bulkManager.repairSelectedRecipes();
break; break;
case 'rematch-metadata':
bulkManager.rematchSelectedRecipes();
break;
case 'reimport-metadata': case 'reimport-metadata':
bulkManager.reimportSelectedRecipes(); bulkManager.reimportSelectedRecipes();
break; break;
@@ -24,6 +24,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]'); const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]'); 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 groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator'); const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
@@ -41,6 +42,7 @@ export class GlobalContextMenu extends BaseContextMenu {
excludedModelsItem?.classList.add('hidden'); excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden'); groupByModelItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden'); repairRecipesItem?.classList.remove('hidden');
rematchRecipesItem?.classList.remove('hidden');
} else { } else {
modelUpdateItem?.classList.remove('hidden'); modelUpdateItem?.classList.remove('hidden');
licenseRefreshItem?.classList.remove('hidden'); licenseRefreshItem?.classList.remove('hidden');
@@ -49,11 +51,28 @@ export class GlobalContextMenu extends BaseContextMenu {
excludedModelsItem?.classList.remove('hidden'); excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden'); groupByModelItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden'); repairRecipesItem?.classList.add('hidden');
rematchRecipesItem?.classList.add('hidden');
} }
this._updateSeparatorVisibility();
super.showMenu(x, y, contextOrigin); 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) { handleMenuAction(action, menuItem) {
switch (action) { switch (action) {
case 'cleanup-example-images-folders': case 'cleanup-example-images-folders':
@@ -81,6 +100,11 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to repair recipes:', error); console.error('Failed to repair recipes:', error);
}); });
break; break;
case 'rematch-recipes':
this.rematchRecipes(menuItem).catch((error) => {
console.error('Failed to rematch recipes:', error);
});
break;
case 'manage-excluded-models': case 'manage-excluded-models':
this.manageExcludedModels(); this.manageExcludedModels();
break; break;
@@ -439,4 +463,143 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to cancel recipe repair:', error); 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 // Repair recipe metadata
this.repairRecipe(recipeId); this.repairRecipe(recipeId);
break; break;
case 'rematch':
// Rematch recipe resources to local models
this.rematchRecipe(recipeId);
break;
case 'reimport': case 'reimport':
this.reimportRecipe(recipeId); this.reimportRecipe(recipeId);
break; 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) { async reimportRecipe(recipeId) {
if (!recipeId) { if (!recipeId) {
showToast('recipes.contextMenu.reimport.missingId', {}, 'error'); showToast('recipes.contextMenu.reimport.missingId', {}, 'error');
+101 -1
View File
@@ -95,7 +95,8 @@ export class BulkManager {
setFavorite: true, setFavorite: true,
unfavorite: true, unfavorite: true,
repairMetadata: 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() { async refreshAllMetadata() {
if (state.selectedModels.size === 0) { if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning'); showToast('toast.models.noModelsSelected', {}, 'warning');
+8 -2
View File
@@ -95,7 +95,10 @@
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span> <i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</div> </div>
<div class="context-menu-item" data-action="repair-metadata"> <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>
<div class="context-menu-item" data-action="reimport-metadata"> <div class="context-menu-item" data-action="reimport-metadata">
<i class="fas fa-undo-alt"></i> <span>{{ t('loras.bulkOperations.reimportMetadata') }}</span> <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> <i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
</div> </div>
<div class="context-menu-item" data-action="repair-recipes"> <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>
</div> </div>
+4 -1
View File
@@ -19,7 +19,10 @@
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> --> <!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<!-- Metadata --> <!-- Metadata -->
<div class="context-menu-item" data-action="repair"> <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>
<div class="context-menu-item" data-action="reimport"> <div class="context-menu-item" data-action="reimport">
<i class="fas fa-undo-alt"></i> {{ t('loras.contextMenu.reimportMetadata') }} <i class="fas fa-undo-alt"></i> {{ t('loras.contextMenu.reimportMetadata') }}
+62
View File
@@ -216,4 +216,66 @@ describe('RecipeSidebarApiClient bulk operations', () => {
expect(restoreScrollPositionMock).not.toHaveBeenCalled(); expect(restoreScrollPositionMock).not.toHaveBeenCalled();
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1); 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 })); document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: 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 {type(cb.__self__) for cb in recipe_callbacks} == {recipe_routes.RecipeRoutes}
assert {cb.__name__ for cb in recipe_callbacks} == {"attach_dependencies"} 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())
+230
View File
@@ -14,6 +14,8 @@ from aiohttp import FormData, web
from aiohttp.test_utils import TestClient, TestServer from aiohttp.test_utils import TestClient, TestServer
from PIL import Image from PIL import Image
import pytest
from py.config import config from py.config import config
from py.routes import base_recipe_routes from py.routes import base_recipe_routes
from py.routes.handlers import recipe_handlers 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.recipes.parsers.civitai_image import CivitaiApiMetadataParser
from py.services.recipes import RecipeValidationError, RecipeNotFoundError from py.services.recipes import RecipeValidationError, RecipeNotFoundError
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.services.websocket_manager import ws_manager
@dataclass @dataclass
@@ -51,6 +54,13 @@ class StubRecipeScanner:
self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {} self.checkpoint_lookup: Dict[str, List[Dict[str, Any]]] = {}
self.image_id_map_override: Dict[str, str] = {} self.image_id_map_override: Dict[str, str] = {}
self.local_hash_cache: Dict[str, Dict[str, Any]] | None = None 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 async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
return None return None
@@ -106,6 +116,65 @@ class StubRecipeScanner:
self.removed.append(recipe_id) self.removed.append(recipe_id)
self.recipes.pop(recipe_id, None) 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: class StubAnalysisService:
"""Captures calls made by analysis routes while returning canned responses.""" """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 harness.scanner.local_hash_cache
assert parser.received_cache is not None assert parser.received_cache is not None
assert parser.received_cache["existing123456"] is parent_item 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"
+253 -1
View File
@@ -6,7 +6,12 @@ from unittest.mock import AsyncMock
import pytest 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.aria2_transfer_state import Aria2TransferStateStore
from py.services import aria2_transfer_state 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"] 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(): def test_build_progress_snapshot_normalizes_numeric_fields():
downloader = Aria2Downloader() downloader = Aria2Downloader()
File diff suppressed because it is too large Load Diff
+89
View File
@@ -172,3 +172,92 @@ def test_generate_download_id(manager):
download_id = manager.generate_download_id() download_id = manager.generate_download_id()
assert isinstance(download_id, str) assert isinstance(download_id, str)
assert download_id 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