Compare commits

..

1 Commits

Author SHA1 Message Date
Will Miao
0a340d397c feat(misc): add VAE and Upscaler model management page 2026-01-31 07:28:10 +08:00
281 changed files with 8116 additions and 40875 deletions

View File

@@ -1,201 +0,0 @@
---
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.
---
# LoRa Manager E2E Testing
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
## Prerequisites
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`)
- Chrome browser available for debugging
- Chrome DevTools MCP connected
## Quick Start Workflow
### 1. Start LoRa Manager Standalone
```python
# Use the provided script to start the server
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188
```
Or manually:
```bash
cd /home/miao/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager
python standalone.py --port 8188
```
Wait for server ready message before proceeding.
### 2. Open Chrome Debug Mode
```bash
# Chrome with remote debugging on port 9222
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras
```
### 3. Connect Chrome DevTools MCP
Ensure the MCP server is connected to Chrome at `http://localhost:9222`.
### 4. Navigate and Interact
Use Chrome DevTools MCP tools to:
- Take snapshots: `take_snapshot`
- Click elements: `click`
- Fill forms: `fill` or `fill_form`
- Evaluate scripts: `evaluate_script`
- Wait for elements: `wait_for`
## Common E2E Test Patterns
### Pattern: Full Page Load Verification
```python
# Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
# Wait for page to load
wait_for(text="LoRAs", timeout=10000)
# Take snapshot to verify UI state
snapshot = take_snapshot()
```
### Pattern: Restart Server for Configuration Changes
```python
# Stop current server (if running)
# Start with new configuration
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port 8188 --restart
# Wait and refresh browser
navigate_page(type="reload", ignoreCache=True)
wait_for(text="LoRAs", timeout=15000)
```
### Pattern: Verify Backend API via Frontend
```python
# Execute script in browser to call backend API
result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
const data = await response.json();
return { count: data.length, firstItem: data[0]?.name };
}
""")
```
### Pattern: Form Submission Flow
```python
# Fill a form (e.g., search or filter)
fill_form(elements=[
{"uid": "search-input", "value": "character"},
])
# Click submit button
click(uid="search-button")
# Wait for results
wait_for(text="Results", timeout=5000)
# Verify results via snapshot
snapshot = take_snapshot()
```
### Pattern: Modal Dialog Interaction
```python
# Open modal (e.g., add LoRA)
click(uid="add-lora-button")
# Wait for modal to appear
wait_for(text="Add LoRA", timeout=3000)
# Fill modal form
fill_form(elements=[
{"uid": "lora-name", "value": "Test LoRA"},
{"uid": "lora-path", "value": "/path/to/lora.safetensors"},
])
# Submit
click(uid="modal-submit-button")
# Wait for success message or close
wait_for(text="Success", timeout=5000)
```
## Available Scripts
### scripts/start_server.py
Starts or restarts the LoRa Manager standalone server.
```bash
python scripts/start_server.py [--port PORT] [--restart] [--wait]
```
Options:
- `--port`: Server port (default: 8188)
- `--restart`: Kill existing server before starting
- `--wait`: Wait for server to be ready before exiting
### scripts/wait_for_server.py
Polls server until ready or timeout.
```bash
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
```
## Test Scenarios Reference
See [references/test-scenarios.md](references/test-scenarios.md) for detailed test scenarios including:
- LoRA list display and filtering
- Model metadata editing
- Recipe creation and management
- Settings configuration
- Import/export functionality
## Network Request Verification
Use `list_network_requests` and `get_network_request` to verify API calls:
```python
# List recent XHR/fetch requests
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Get details of specific request
details = get_network_request(reqid=123)
```
## Console Message Monitoring
```python
# Check for errors or warnings
messages = list_console_messages(types=["error", "warn"])
```
## Performance Testing
```python
# Start performance trace
performance_start_trace(reload=True, autoStop=False)
# Perform actions...
# Stop and analyze
results = performance_stop_trace()
```
## Cleanup
Always ensure proper cleanup after tests:
1. Stop the standalone server
2. Close browser pages (keep at least one open)
3. Clear temporary data if needed

View File

@@ -1,324 +0,0 @@
# Chrome DevTools MCP Cheatsheet for LoRa Manager
Quick reference for common MCP commands used in LoRa Manager E2E testing.
## Navigation
```python
# Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
# Reload page with cache clear
navigate_page(type="reload", ignoreCache=True)
# Go back/forward
navigate_page(type="back")
navigate_page(type="forward")
```
## Waiting
```python
# Wait for text to appear
wait_for(text="LoRAs", timeout=10000)
# Wait for specific element (via evaluate_script)
evaluate_script(function="""
() => {
return new Promise((resolve) => {
const check = () => {
if (document.querySelector('.lora-card')) {
resolve(true);
} else {
setTimeout(check, 100);
}
};
check();
});
}
""")
```
## Taking Snapshots
```python
# Full page snapshot
snapshot = take_snapshot()
# Verbose snapshot (more details)
snapshot = take_snapshot(verbose=True)
# Save to file
take_snapshot(filePath="test-snapshots/page-load.json")
```
## Element Interaction
```python
# Click element
click(uid="element-uid-from-snapshot")
# Double click
click(uid="element-uid", dblClick=True)
# Fill input
fill(uid="search-input", value="test query")
# Fill multiple inputs
fill_form(elements=[
{"uid": "input-1", "value": "value 1"},
{"uid": "input-2", "value": "value 2"},
])
# Hover
hover(uid="lora-card-1")
# Upload file
upload_file(uid="file-input", filePath="/path/to/file.safetensors")
```
## Keyboard Input
```python
# Press key
press_key(key="Enter")
press_key(key="Escape")
press_key(key="Tab")
# Keyboard shortcuts
press_key(key="Control+A") # Select all
press_key(key="Control+F") # Find
```
## JavaScript Evaluation
```python
# Simple evaluation
result = evaluate_script(function="() => document.title")
# Async evaluation
result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
return await response.json();
}
""")
# Check element existence
exists = evaluate_script(function="""
() => document.querySelector('.lora-card') !== null
""")
# Get element count
count = evaluate_script(function="""
() => document.querySelectorAll('.lora-card').length
""")
```
## Network Monitoring
```python
# List all network requests
requests = list_network_requests()
# Filter by resource type
xhr_requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Get specific request details
details = get_network_request(reqid=123)
# Include preserved requests from previous navigations
all_requests = list_network_requests(includePreservedRequests=True)
```
## Console Monitoring
```python
# List all console messages
messages = list_console_messages()
# Filter by type
errors = list_console_messages(types=["error", "warn"])
# Include preserved messages
all_messages = list_console_messages(includePreservedMessages=True)
# Get specific message
details = get_console_message(msgid=1)
```
## Performance Testing
```python
# Start trace with page reload
performance_start_trace(reload=True, autoStop=False)
# Start trace without reload
performance_start_trace(reload=False, autoStop=True, filePath="trace.json.gz")
# Stop trace
results = performance_stop_trace()
# Stop and save
performance_stop_trace(filePath="trace-results.json.gz")
# Analyze specific insight
insight = performance_analyze_insight(
insightSetId="results.insightSets[0].id",
insightName="LCPBreakdown"
)
```
## Page Management
```python
# List open pages
pages = list_pages()
# Select a page
select_page(pageId=0, bringToFront=True)
# Create new page
new_page(url="http://127.0.0.1:8188/loras")
# Close page (keep at least one open!)
close_page(pageId=1)
# Resize page
resize_page(width=1920, height=1080)
```
## Screenshots
```python
# Full page screenshot
take_screenshot(fullPage=True)
# Viewport screenshot
take_screenshot()
# Element screenshot
take_screenshot(uid="lora-card-1")
# Save to file
take_screenshot(filePath="screenshots/page.png", format="png")
# JPEG with quality
take_screenshot(filePath="screenshots/page.jpg", format="jpeg", quality=90)
```
## Dialog Handling
```python
# Accept dialog
handle_dialog(action="accept")
# Accept with text input
handle_dialog(action="accept", promptText="user input")
# Dismiss dialog
handle_dialog(action="dismiss")
```
## Device Emulation
```python
# Mobile viewport
emulate(viewport={"width": 375, "height": 667, "isMobile": True, "hasTouch": True})
# Tablet viewport
emulate(viewport={"width": 768, "height": 1024, "isMobile": True, "hasTouch": True})
# Desktop viewport
emulate(viewport={"width": 1920, "height": 1080})
# Network throttling
emulate(networkConditions="Slow 3G")
emulate(networkConditions="Fast 4G")
# CPU throttling
emulate(cpuThrottlingRate=4) # 4x slowdown
# Geolocation
emulate(geolocation={"latitude": 37.7749, "longitude": -122.4194})
# User agent
emulate(userAgent="Mozilla/5.0 (Custom)")
# Reset emulation
emulate(viewport=None, networkConditions="No emulation", userAgent=None)
```
## Drag and Drop
```python
# Drag element to another
drag(from_uid="draggable-item", to_uid="drop-zone")
```
## Common LoRa Manager Test Patterns
### Verify LoRA Cards Loaded
```python
navigate_page(type="url", url="http://127.0.0.1:8188/loras")
wait_for(text="LoRAs", timeout=10000)
# Check if cards loaded
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
return {
count: cards.length,
hasData: cards.length > 0
};
}
""")
```
### Search and Verify Results
```python
fill(uid="search-input", value="character")
press_key(key="Enter")
wait_for(timeout=2000) # Wait for debounce
# Check results
result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name || c.textContent);
return { count: cards.length, names };
}
""")
```
### Check API Response
```python
# Trigger API call
evaluate_script(function="""
() => window.loraApiCallPromise = fetch('/loras/api/list').then(r => r.json())
""")
# Wait and get result
import time
time.sleep(1)
result = evaluate_script(function="""
async () => await window.loraApiCallPromise
""")
```
### Monitor Console for Errors
```python
# Before test: clear console (navigate reloads)
navigate_page(type="reload")
# ... perform actions ...
# Check for errors
errors = list_console_messages(types=["error"])
assert len(errors) == 0, f"Console errors: {errors}"
```

View File

@@ -1,272 +0,0 @@
# LoRa Manager E2E Test Scenarios
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
## Table of Contents
1. [LoRA List Page](#lora-list-page)
2. [Model Details](#model-details)
3. [Recipes](#recipes)
4. [Settings](#settings)
5. [Import/Export](#importexport)
---
## LoRA List Page
### Scenario: Page Load and Display
**Objective**: Verify the LoRA list page loads correctly and displays models.
**Steps**:
1. Navigate to `http://127.0.0.1:8188/loras`
2. Wait for page title "LoRAs" to appear
3. Take snapshot to verify:
- Header with "LoRAs" title is visible
- Search/filter controls are present
- Grid/list view toggle exists
- LoRA cards are displayed (if models exist)
- Pagination controls (if applicable)
**Expected Result**: Page loads without errors, UI elements are present.
### Scenario: Search Functionality
**Objective**: Verify search filters LoRA models correctly.
**Steps**:
1. Ensure at least one LoRA exists with known name (e.g., "test-character")
2. Navigate to LoRA list page
3. Enter search term in search box: "test"
4. Press Enter or click search button
5. Wait for results to update
**Expected Result**: Only LoRAs matching search term are displayed.
**Verification Script**:
```python
# After search, verify filtered results
evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
const names = Array.from(cards).map(c => c.dataset.name);
return { count: cards.length, names };
}
""")
```
### Scenario: Filter by Tags
**Objective**: Verify tag filtering works correctly.
**Steps**:
1. Navigate to LoRA list page
2. Click on a tag (e.g., "character", "style")
3. Wait for filtered results
**Expected Result**: Only LoRAs with selected tag are displayed.
### Scenario: View Mode Toggle
**Objective**: Verify grid/list view toggle works.
**Steps**:
1. Navigate to LoRA list page
2. Click list view button
3. Verify list layout
4. Click grid view button
5. Verify grid layout
**Expected Result**: View mode changes correctly, layout updates.
---
## Model Details
### Scenario: Open Model Details
**Objective**: Verify clicking a LoRA opens its details.
**Steps**:
1. Navigate to LoRA list page
2. Click on a LoRA card
3. Wait for details panel/modal to open
**Expected Result**: Details panel shows:
- Model name
- Preview image
- Metadata (trigger words, tags, etc.)
- Action buttons (edit, delete, etc.)
### Scenario: Edit Model Metadata
**Objective**: Verify metadata editing works end-to-end.
**Steps**:
1. Open a LoRA's details
2. Click "Edit" button
3. Modify trigger words field
4. Add/remove tags
5. Save changes
6. Refresh page
7. Reopen the same LoRA
**Expected Result**: Changes persist after refresh.
### Scenario: Delete Model
**Objective**: Verify model deletion works.
**Steps**:
1. Open a LoRA's details
2. Click "Delete" button
3. Confirm deletion in dialog
4. Wait for removal
**Expected Result**: Model removed from list, success message shown.
---
## Recipes
### Scenario: Recipe List Display
**Objective**: Verify recipes page loads and displays recipes.
**Steps**:
1. Navigate to `http://127.0.0.1:8188/recipes`
2. Wait for "Recipes" title
3. Take snapshot
**Expected Result**: Recipe list displayed with cards/items.
### Scenario: Create New Recipe
**Objective**: Verify recipe creation workflow.
**Steps**:
1. Navigate to recipes page
2. Click "New Recipe" button
3. Fill recipe form:
- Name: "Test Recipe"
- Description: "E2E test recipe"
- Add LoRA models
4. Save recipe
5. Verify recipe appears in list
**Expected Result**: New recipe created and displayed.
### Scenario: Apply Recipe
**Objective**: Verify applying a recipe to ComfyUI.
**Steps**:
1. Open a recipe
2. Click "Apply" or "Load in ComfyUI"
3. Verify action completes
**Expected Result**: Recipe applied successfully.
---
## Settings
### Scenario: Settings Page Load
**Objective**: Verify settings page displays correctly.
**Steps**:
1. Navigate to `http://127.0.0.1:8188/settings`
2. Wait for "Settings" title
3. Take snapshot
**Expected Result**: Settings form with various options displayed.
### Scenario: Change Setting and Restart
**Objective**: Verify settings persist after restart.
**Steps**:
1. Navigate to settings page
2. Change a setting (e.g., default view mode)
3. Save settings
4. Restart server: `python scripts/start_server.py --restart --wait`
5. Refresh browser page
6. Navigate to settings
**Expected Result**: Changed setting value persists.
---
## Import/Export
### Scenario: Export Models List
**Objective**: Verify export functionality.
**Steps**:
1. Navigate to LoRA list
2. Click "Export" button
3. Select format (JSON/CSV)
4. Download file
**Expected Result**: File downloaded with correct data.
### Scenario: Import Models
**Objective**: Verify import functionality.
**Steps**:
1. Prepare import file
2. Navigate to import page
3. Upload file
4. Verify import results
**Expected Result**: Models imported successfully, confirmation shown.
---
## API Integration Tests
### Scenario: Verify API Endpoints
**Objective**: Verify backend API responds correctly.
**Test via browser console**:
```javascript
// List LoRAs
fetch('/loras/api/list').then(r => r.json()).then(console.log)
// Get LoRA details
fetch('/loras/api/detail/<id>').then(r => r.json()).then(console.log)
// Search LoRAs
fetch('/loras/api/search?q=test').then(r => r.json()).then(console.log)
```
**Expected Result**: APIs return valid JSON with expected structure.
---
## Console Error Monitoring
During all tests, monitor browser console for errors:
```python
# Check for JavaScript errors
messages = list_console_messages(types=["error"])
assert len(messages) == 0, f"Console errors found: {messages}"
```
## Network Request Verification
Verify key API calls are made:
```python
# List XHR requests
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Look for specific endpoints
lora_list_requests = [r for r in requests if "/api/list" in r.get("url", "")]
assert len(lora_list_requests) > 0, "LoRA list API not called"
```

View File

@@ -1,193 +0,0 @@
#!/usr/bin/env python3
"""
Example E2E test demonstrating LoRa Manager testing workflow.
This script shows how to:
1. Start the standalone server
2. Use Chrome DevTools MCP to interact with the UI
3. Verify functionality end-to-end
Note: This is a template. Actual execution requires Chrome DevTools MCP.
"""
import subprocess
import sys
import time
def run_test():
"""Run example E2E test flow."""
print("=" * 60)
print("LoRa Manager E2E Test Example")
print("=" * 60)
# Step 1: Start server
print("\n[1/5] Starting LoRa Manager standalone server...")
result = subprocess.run(
[sys.executable, "start_server.py", "--port", "8188", "--wait", "--timeout", "30"],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f"Failed to start server: {result.stderr}")
return 1
print("Server ready!")
# Step 2: Open Chrome (manual step - show command)
print("\n[2/5] Open Chrome with debug mode:")
print("google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:8188/loras")
print("(In actual test, this would be automated via MCP)")
# Step 3: Navigate and verify page load
print("\n[3/5] Page Load Verification:")
print("""
MCP Commands to execute:
1. navigate_page(type="url", url="http://127.0.0.1:8188/loras")
2. wait_for(text="LoRAs", timeout=10000)
3. snapshot = take_snapshot()
""")
# Step 4: Test search functionality
print("\n[4/5] Search Functionality Test:")
print("""
MCP Commands to execute:
1. fill(uid="search-input", value="test")
2. press_key(key="Enter")
3. wait_for(text="Results", timeout=5000)
4. result = evaluate_script(function="""
() => {
const cards = document.querySelectorAll('.lora-card');
return { count: cards.length };
}
""")
""")
# Step 5: Verify API
print("\n[5/5] API Verification:")
print("""
MCP Commands to execute:
1. api_result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
const data = await response.json();
return { count: data.length, status: response.status };
}
""")
2. Verify api_result['status'] == 200
""")
print("\n" + "=" * 60)
print("Test flow completed!")
print("=" * 60)
return 0
def example_restart_flow():
"""Example: Testing configuration change that requires restart."""
print("\n" + "=" * 60)
print("Example: Server Restart Flow")
print("=" * 60)
print("""
Scenario: Change setting and verify after restart
Steps:
1. Navigate to settings page
- navigate_page(type="url", url="http://127.0.0.1:8188/settings")
2. Change a setting (e.g., theme)
- fill(uid="theme-select", value="dark")
- click(uid="save-settings-button")
3. Restart server
- subprocess.run([python, "start_server.py", "--restart", "--wait"])
4. Refresh browser
- navigate_page(type="reload", ignoreCache=True)
- wait_for(text="LoRAs", timeout=15000)
5. Verify setting persisted
- navigate_page(type="url", url="http://127.0.0.1:8188/settings")
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
- assert theme == "dark"
""")
def example_modal_interaction():
"""Example: Testing modal dialog interaction."""
print("\n" + "=" * 60)
print("Example: Modal Dialog Interaction")
print("=" * 60)
print("""
Scenario: Add new LoRA via modal
Steps:
1. Open modal
- click(uid="add-lora-button")
- wait_for(text="Add LoRA", timeout=3000)
2. Fill form
- fill_form(elements=[
{"uid": "lora-name", "value": "Test Character"},
{"uid": "lora-path", "value": "/models/test.safetensors"},
])
3. Submit
- click(uid="modal-submit-button")
4. Verify success
- wait_for(text="Successfully added", timeout=5000)
- snapshot = take_snapshot()
""")
def example_network_monitoring():
"""Example: Network request monitoring."""
print("\n" + "=" * 60)
print("Example: Network Request Monitoring")
print("=" * 60)
print("""
Scenario: Verify API calls during user interaction
Steps:
1. Clear network log (implicit on navigation)
- navigate_page(type="url", url="http://127.0.0.1:8188/loras")
2. Perform action that triggers API call
- fill(uid="search-input", value="character")
- press_key(key="Enter")
3. List network requests
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
4. Find search API call
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
- assert len(search_requests) > 0, "Search API was not called"
5. Get request details
- if search_requests:
details = get_network_request(reqid=search_requests[0]["reqid"])
- Verify request method, response status, etc.
""")
if __name__ == "__main__":
print("LoRa Manager E2E Test Examples\n")
print("This script demonstrates E2E testing patterns.\n")
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
run_test()
example_restart_flow()
example_modal_interaction()
example_network_monitoring()
print("\n" + "=" * 60)
print("All examples shown!")
print("=" * 60)

View File

@@ -1,169 +0,0 @@
#!/usr/bin/env python3
"""
Start or restart LoRa Manager standalone server for E2E testing.
"""
import argparse
import subprocess
import sys
import time
import socket
import signal
import os
def find_server_process(port: int) -> list[int]:
"""Find PIDs of processes listening on the given port."""
try:
result = subprocess.run(
["lsof", "-ti", f":{port}"],
capture_output=True,
text=True,
check=False
)
if result.returncode == 0 and result.stdout.strip():
return [int(pid) for pid in result.stdout.strip().split("\n") if pid]
except FileNotFoundError:
# lsof not available, try netstat
try:
result = subprocess.run(
["netstat", "-tlnp"],
capture_output=True,
text=True,
check=False
)
pids = []
for line in result.stdout.split("\n"):
if f":{port}" in line:
parts = line.split()
for part in parts:
if "/" in part:
try:
pid = int(part.split("/")[0])
pids.append(pid)
except ValueError:
pass
return pids
except FileNotFoundError:
pass
return []
def kill_server(port: int) -> None:
"""Kill processes using the specified port."""
pids = find_server_process(port)
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
print(f"Sent SIGTERM to process {pid}")
except ProcessLookupError:
pass
# Wait for processes to terminate
time.sleep(1)
# Force kill if still running
pids = find_server_process(port)
for pid in pids:
try:
os.kill(pid, signal.SIGKILL)
print(f"Sent SIGKILL to process {pid}")
except ProcessLookupError:
pass
def is_server_ready(port: int, timeout: float = 0.5) -> bool:
"""Check if server is accepting connections."""
try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
return True
except (socket.timeout, ConnectionRefusedError, OSError):
return False
def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready."""
start = time.time()
while time.time() - start < timeout:
if is_server_ready(port):
return True
time.sleep(0.5)
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Start LoRa Manager standalone server for E2E testing"
)
parser.add_argument(
"--port",
type=int,
default=8188,
help="Server port (default: 8188)"
)
parser.add_argument(
"--restart",
action="store_true",
help="Kill existing server before starting"
)
parser.add_argument(
"--wait",
action="store_true",
help="Wait for server to be ready before exiting"
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="Timeout for waiting (default: 30)"
)
args = parser.parse_args()
# Get project root (parent of .agents directory)
script_dir = os.path.dirname(os.path.abspath(__file__))
skill_dir = os.path.dirname(script_dir)
project_root = os.path.dirname(os.path.dirname(os.path.dirname(skill_dir)))
# Restart if requested
if args.restart:
print(f"Killing existing server on port {args.port}...")
kill_server(args.port)
time.sleep(1)
# Check if already running
if is_server_ready(args.port):
print(f"Server already running on port {args.port}")
return 0
# Start server
print(f"Starting LoRa Manager standalone server on port {args.port}...")
cmd = [sys.executable, "standalone.py", "--port", str(args.port)]
# Start in background
process = subprocess.Popen(
cmd,
cwd=project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
print(f"Server process started with PID {process.pid}")
# Wait for ready if requested
if args.wait:
print(f"Waiting for server to be ready (timeout: {args.timeout}s)...")
if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0
else:
print(f"Timeout waiting for server")
return 1
print(f"Server starting at http://127.0.0.1:{args.port}/loras")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,61 +0,0 @@
#!/usr/bin/env python3
"""
Wait for LoRa Manager server to become ready.
"""
import argparse
import socket
import sys
import time
def is_server_ready(port: int, timeout: float = 0.5) -> bool:
"""Check if server is accepting connections."""
try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
return True
except (socket.timeout, ConnectionRefusedError, OSError):
return False
def wait_for_server(port: int, timeout: int = 30) -> bool:
"""Wait for server to become ready."""
start = time.time()
while time.time() - start < timeout:
if is_server_ready(port):
return True
time.sleep(0.5)
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Wait for LoRa Manager server to become ready"
)
parser.add_argument(
"--port",
type=int,
default=8188,
help="Server port (default: 8188)"
)
parser.add_argument(
"--timeout",
type=int,
default=30,
help="Timeout in seconds (default: 30)"
)
args = parser.parse_args()
print(f"Waiting for server on port {args.port} (timeout: {args.timeout}s)...")
if wait_for_server(args.port, args.timeout):
print(f"Server ready at http://127.0.0.1:{args.port}/loras")
return 0
else:
print(f"Timeout: Server not ready after {args.timeout}s")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,153 +0,0 @@
# Recipe Batch Import Feature Design
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend │
├─────────────────────────────────────────────────────────────────┤
│ BatchImportManager.js │
│ ├── InputCollector (收集URL列表/目录路径) │
│ ├── ConcurrencyController (自适应并发控制) │
│ ├── ProgressTracker (进度追踪) │
│ └── ResultAggregator (结果汇总) │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_modal.html │
│ └── 批量导入UI组件 │
├─────────────────────────────────────────────────────────────────┤
│ batch_import_progress.css │
│ └── 进度显示样式 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Backend │
├─────────────────────────────────────────────────────────────────┤
│ py/routes/handlers/recipe_handlers.py │
│ ├── start_batch_import() - 启动批量导入 │
│ ├── get_batch_import_progress() - 查询进度 │
│ └── cancel_batch_import() - 取消导入 │
├─────────────────────────────────────────────────────────────────┤
│ py/services/batch_import_service.py │
│ ├── 自适应并发执行 │
│ ├── 结果汇总 │
│ └── WebSocket进度广播 │
└─────────────────────────────────────────────────────────────────┘
```
## API Endpoints
| 端点 | 方法 | 说明 |
|------|------|------|
| `/api/lm/recipes/batch-import/start` | POST | 启动批量导入,返回 operation_id |
| `/api/lm/recipes/batch-import/progress` | GET | 查询进度状态 |
| `/api/lm/recipes/batch-import/cancel` | POST | 取消导入 |
## Backend Implementation Details
### BatchImportService
Location: `py/services/batch_import_service.py`
Key classes:
- `BatchImportItem`: Dataclass for individual import item
- `BatchImportProgress`: Dataclass for tracking progress
- `BatchImportService`: Main service class
Features:
- Adaptive concurrency control (adjusts based on success/failure rate)
- WebSocket progress broadcasting
- Graceful error handling (individual failures don't stop the batch)
- Result aggregation
### WebSocket Message Format
```json
{
"type": "batch_import_progress",
"operation_id": "xxx",
"total": 50,
"completed": 23,
"success": 21,
"failed": 2,
"skipped": 0,
"current_item": "image_024.png",
"status": "running"
}
```
### Input Types
1. **URL List**: Array of URLs (http/https)
2. **Local Paths**: Array of local file paths
3. **Directory**: Path to directory with optional recursive flag
### Error Handling
- Invalid URLs/paths: Skip and record error
- Download failures: Record error, continue
- Metadata extraction failures: Mark as "no metadata"
- Duplicate detection: Option to skip duplicates
## Frontend Implementation Details (TODO)
### UI Components
1. **BatchImportModal**: Main modal with tabs for URLs/Directory input
2. **ProgressDisplay**: Real-time progress bar and status
3. **ResultsSummary**: Final results with success/failure breakdown
### Adaptive Concurrency Controller
```javascript
class AdaptiveConcurrencyController {
constructor(options = {}) {
this.minConcurrency = options.minConcurrency || 1;
this.maxConcurrency = options.maxConcurrency || 5;
this.currentConcurrency = options.initialConcurrency || 3;
}
adjustConcurrency(taskDuration, success) {
if (success && taskDuration < 1000 && this.currentConcurrency < this.maxConcurrency) {
this.currentConcurrency = Math.min(this.currentConcurrency + 1, this.maxConcurrency);
}
if (!success || taskDuration > 10000) {
this.currentConcurrency = Math.max(this.currentConcurrency - 1, this.minConcurrency);
}
return this.currentConcurrency;
}
}
```
## File Structure
```
Backend (implemented):
├── py/services/batch_import_service.py # 后端服务
├── py/routes/handlers/batch_import_handler.py # API处理器 (added to recipe_handlers.py)
├── tests/services/test_batch_import_service.py # 单元测试
└── tests/routes/test_batch_import_routes.py # API集成测试
Frontend (TODO):
├── static/js/managers/BatchImportManager.js # 主管理器
├── static/js/managers/batch/ # 子模块
│ ├── ConcurrencyController.js # 并发控制
│ ├── ProgressTracker.js # 进度追踪
│ └── ResultAggregator.js # 结果汇总
├── static/css/components/batch-import-modal.css # 样式
└── templates/components/batch_import_modal.html # Modal模板
```
## Implementation Status
- [x] Backend BatchImportService
- [x] Backend API handlers
- [x] WebSocket progress broadcasting
- [x] Unit tests
- [x] Integration tests
- [ ] Frontend BatchImportManager
- [ ] Frontend UI components
- [ ] E2E tests

View File

@@ -1,31 +0,0 @@
name: Update Supporters in README
on:
push:
paths:
- 'data/supporters.json'
branches:
- main
workflow_dispatch: # Allow manual trigger
jobs:
update-readme:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Update README
run: python scripts/update_supporters.py
- name: Commit and push changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "docs: auto-update supporters list in README"
file_pattern: "README.md"

4
.gitignore vendored
View File

@@ -14,12 +14,8 @@ model_cache/
# agent # agent
.opencode/ .opencode/
.claude/
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/
vue-widgets/.vite/ vue-widgets/.vite/
vue-widgets/dist/ vue-widgets/dist/
# Hypothesis test cache
.hypothesis/

View File

@@ -1,464 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/willmiao/ComfyUI-Lora-Manager/.specs/metadata.schema.json",
"title": "ComfyUI LoRa Manager Model Metadata",
"description": "Schema for .metadata.json sidecar files used by ComfyUI LoRa Manager",
"type": "object",
"oneOf": [
{
"title": "LoRA Model Metadata",
"properties": {
"file_name": {
"type": "string",
"description": "Filename without extension"
},
"model_name": {
"type": "string",
"description": "Display name of the model"
},
"file_path": {
"type": "string",
"description": "Full absolute path to the model file"
},
"size": {
"type": "integer",
"minimum": 0,
"description": "File size in bytes at time of import/download"
},
"modified": {
"type": "number",
"description": "Unix timestamp when model was imported/added (Date Added)"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$",
"description": "SHA256 hash of the model file (lowercase)"
},
"base_model": {
"type": "string",
"description": "Base model type (SD1.5, SD2.1, SDXL, SD3, Flux, Unknown, etc.)"
},
"preview_url": {
"type": "string",
"description": "Path to preview image file"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "NSFW level using bitmask values: 0 (none), 1 (PG), 2 (PG13), 4 (R), 8 (X), 16 (XXX), 32 (Blocked)"
},
"notes": {
"type": "string",
"default": "",
"description": "User-defined notes"
},
"from_civitai": {
"type": "boolean",
"default": true,
"description": "Whether the model originated from Civitai"
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Model tags"
},
"modelDescription": {
"type": "string",
"default": "",
"description": "Full model description"
},
"civitai_deleted": {
"type": "boolean",
"default": false,
"description": "Whether the model was deleted from Civitai"
},
"favorite": {
"type": "boolean",
"default": false,
"description": "Whether the model is marked as favorite"
},
"exclude": {
"type": "boolean",
"default": false,
"description": "Whether to exclude from cache/scanning"
},
"db_checked": {
"type": "boolean",
"default": false,
"description": "Whether checked against archive database"
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false,
"description": "Skip this model during bulk metadata refresh"
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null,
"description": "Last provider that supplied metadata"
},
"last_checked_at": {
"type": "number",
"default": 0,
"description": "Unix timestamp of last metadata check"
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed",
"description": "Hash calculation status"
},
"usage_tips": {
"type": "string",
"default": "{}",
"description": "JSON string containing recommended usage parameters (LoRA only)"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
},
{
"title": "Checkpoint Model Metadata",
"properties": {
"file_name": {
"type": "string"
},
"model_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"size": {
"type": "integer",
"minimum": 0
},
"modified": {
"type": "number"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"base_model": {
"type": "string"
},
"preview_url": {
"type": "string"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"maximum": 3,
"default": 0
},
"notes": {
"type": "string",
"default": ""
},
"from_civitai": {
"type": "boolean",
"default": true
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"modelDescription": {
"type": "string",
"default": ""
},
"civitai_deleted": {
"type": "boolean",
"default": false
},
"favorite": {
"type": "boolean",
"default": false
},
"exclude": {
"type": "boolean",
"default": false
},
"db_checked": {
"type": "boolean",
"default": false
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null
},
"last_checked_at": {
"type": "number",
"default": 0
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed"
},
"sub_type": {
"type": "string",
"default": "checkpoint",
"description": "Model sub-type (checkpoint, diffusion_model, etc.)"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
},
{
"title": "Embedding Model Metadata",
"properties": {
"file_name": {
"type": "string"
},
"model_name": {
"type": "string"
},
"file_path": {
"type": "string"
},
"size": {
"type": "integer",
"minimum": 0
},
"modified": {
"type": "number"
},
"sha256": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"base_model": {
"type": "string"
},
"preview_url": {
"type": "string"
},
"preview_nsfw_level": {
"type": "integer",
"minimum": 0,
"maximum": 3,
"default": 0
},
"notes": {
"type": "string",
"default": ""
},
"from_civitai": {
"type": "boolean",
"default": true
},
"civitai": {
"$ref": "#/definitions/civitaiObject"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"modelDescription": {
"type": "string",
"default": ""
},
"civitai_deleted": {
"type": "boolean",
"default": false
},
"favorite": {
"type": "boolean",
"default": false
},
"exclude": {
"type": "boolean",
"default": false
},
"db_checked": {
"type": "boolean",
"default": false
},
"skip_metadata_refresh": {
"type": "boolean",
"default": false
},
"metadata_source": {
"type": ["string", "null"],
"enum": ["civitai_api", "civarchive", "archive_db", null],
"default": null
},
"last_checked_at": {
"type": "number",
"default": 0
},
"hash_status": {
"type": "string",
"enum": ["pending", "calculating", "completed", "failed"],
"default": "completed"
},
"sub_type": {
"type": "string",
"default": "embedding",
"description": "Model sub-type"
}
},
"required": [
"file_name",
"model_name",
"file_path",
"size",
"modified",
"sha256",
"base_model"
],
"additionalProperties": true
}
],
"definitions": {
"civitaiObject": {
"type": "object",
"default": {},
"description": "Civitai/CivArchive API data and user-defined fields",
"properties": {
"id": {
"type": "integer",
"description": "Version ID from Civitai"
},
"modelId": {
"type": "integer",
"description": "Model ID from Civitai"
},
"name": {
"type": "string",
"description": "Version name"
},
"description": {
"type": "string",
"description": "Version description"
},
"baseModel": {
"type": "string",
"description": "Base model type from Civitai"
},
"type": {
"type": "string",
"description": "Model type (checkpoint, embedding, etc.)"
},
"trainedWords": {
"type": "array",
"items": {
"type": "string"
},
"description": "Trigger words for the model (from API or user-defined)"
},
"customImages": {
"type": "array",
"items": {
"type": "object"
},
"description": "Custom example images added by user"
},
"model": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"description": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"files": {
"type": "array",
"items": {
"type": "object"
}
},
"images": {
"type": "array",
"items": {
"type": "object"
}
},
"creator": {
"type": "object"
}
},
"additionalProperties": true
},
"usageTips": {
"type": "object",
"description": "Structure for usage_tips JSON string (LoRA models)",
"properties": {
"strength_min": {
"type": "number",
"description": "Minimum recommended model strength"
},
"strength_max": {
"type": "number",
"description": "Maximum recommended model strength"
},
"strength_range": {
"type": "string",
"description": "Human-readable strength range"
},
"strength": {
"type": "number",
"description": "Single recommended strength value"
},
"clip_strength": {
"type": "number",
"description": "Recommended CLIP/embedding strength"
},
"clip_skip": {
"type": "integer",
"description": "Recommended CLIP skip value"
}
},
"additionalProperties": true
}
}
}

183
AGENTS.md
View File

@@ -25,127 +25,168 @@ pytest tests/test_recipes.py::test_function_name
# Run backend tests with coverage # Run backend tests with coverage
COVERAGE_FILE=coverage/backend/.coverage pytest \ COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py --cov=standalone \ --cov=py \
--cov=standalone \
--cov-report=term-missing \ --cov-report=term-missing \
--cov-report=html:coverage/backend/html \ --cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml --cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json
``` ```
### Frontend Development (Standalone Web UI) ### Frontend Development
```bash ```bash
# Install frontend dependencies
npm install npm install
npm test # Run all tests (JS + Vue)
npm run test:js # Run JS tests only
npm run test:watch # Watch mode
npm run test:coverage # Generate coverage report
```
### Vue Widget Development # Run frontend tests
npm test
```bash # Run frontend tests in watch mode
cd vue-widgets npm run test:watch
npm install
npm run dev # Build in watch mode # Run frontend tests with coverage
npm run build # Build production bundle npm run test:coverage
npm run typecheck # Run TypeScript type checking
npm test # Run Vue widget tests
npm run test:watch # Watch mode
npm run test:coverage # Generate coverage report
``` ```
## Python Code Style ## Python Code Style
### Imports & Formatting ### Imports
- Use `from __future__ import annotations` for forward references - Use `from __future__ import annotations` for forward references in type hints
- Group imports: standard library, third-party, local (blank line separated) - Group imports: standard library, third-party, local (separated by blank lines)
- Absolute imports within `py/`: `from ..services import X` - Use absolute imports within `py/` package: `from ..services import X`
- PEP 8 with 4-space indentation, type hints required - Mock ComfyUI dependencies in tests using `tests/conftest.py` patterns
### Formatting & Types
- PEP 8 with 4-space indentation
- Type hints required for function signatures and class attributes
- Use `TYPE_CHECKING` guard for type-checking-only imports
- Prefer dataclasses for simple data containers
- Use `Optional[T]` for nullable types, `Union[T, None]` only when necessary
### Naming Conventions ### Naming Conventions
- Files: `snake_case.py`, Classes: `PascalCase`, Functions/vars: `snake_case` - Files: `snake_case.py` (e.g., `model_scanner.py`, `lora_service.py`)
- Constants: `UPPER_SNAKE_CASE`, Private: `_protected`, `__mangled` - Classes: `PascalCase` (e.g., `ModelScanner`, `LoraService`)
- Functions/variables: `snake_case` (e.g., `get_instance`, `model_type`)
- Constants: `UPPER_SNAKE_CASE` (e.g., `VALID_LORA_TYPES`)
- Private members: `_single_underscore` (protected), `__double_underscore` (name-mangled)
### Error Handling & Async ### Error Handling
- Use `logging.getLogger(__name__)`, define custom exceptions in `py/services/errors.py` - Use `logging.getLogger(__name__)` for module-level loggers
- `async def` for I/O, `@pytest.mark.asyncio` for async tests - Define custom exceptions in `py/services/errors.py`
- Singleton with `asyncio.Lock`: see `ModelScanner.get_instance()` - Use `asyncio.Lock` for thread-safe singleton patterns
- Return `aiohttp.web.json_response` or `web.Response` - Raise specific exceptions with descriptive messages
- Log errors at appropriate levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
### Testing ### Async Patterns
- `pytest` with `--import-mode=importlib` - Use `async def` for I/O-bound operations
- Fixtures in `tests/conftest.py`, use `tmp_path_factory` for isolation - Mark async tests with `@pytest.mark.asyncio`
- Mark tests needing real paths: `@pytest.mark.no_settings_dir_isolation` - Use `async with` for context managers
- Mock ComfyUI dependencies via conftest patterns - Singleton pattern with class-level locks: see `ModelScanner.get_instance()`
- Use `aiohttp.web.Response` for HTTP responses
## JavaScript/TypeScript Code Style ### Testing Patterns
- Use `pytest` with `--import-mode=importlib`
- Fixtures in `tests/conftest.py` handle ComfyUI mocking
- Use `@pytest.mark.no_settings_dir_isolation` for tests needing real paths
- Test files: `tests/test_*.py`
- Use `tmp_path_factory` for temporary directory isolation
## JavaScript Code Style
### Imports & Modules ### Imports & Modules
- ES modules: `import { app } from "../../scripts/app.js"` for ComfyUI - ES modules with `import`/`export`
- Vue: `import { ref, computed } from 'vue'`, type imports: `import type { Foo }` - Use `import { app } from "../../scripts/app.js"` for ComfyUI integration
- Export named functions: `export function foo() {}` - Export named functions/classes: `export function foo() {}`
- Widget files use `*_widget.js` suffix
### Naming & Formatting ### Naming & Formatting
- camelCase for functions/vars/props, PascalCase for classes - camelCase for functions, variables, object properties
- Constants: `UPPER_SNAKE_CASE`, Files: `snake_case.js` or `kebab-case.js` - PascalCase for classes/constructors
- Constants: `UPPER_SNAKE_CASE` (e.g., `CONVERTED_TYPE`)
- Files: `snake_case.js` or `kebab-case.js`
- 2-space indentation preferred (follow existing file conventions) - 2-space indentation preferred (follow existing file conventions)
- Vue Single File Components: `<script setup lang="ts">` preferred
### Widget Development ### Widget Development
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)` - Use `app.registerExtension()` to register ComfyUI extensions
- Event handlers via `addEventListener` or widget callbacks - Use `node.addDOMWidget(name, type, element, options)` for custom widgets
- Shared utilities: `web/comfyui/utils.js` - Event handlers attached via `addEventListener` or widget callbacks
- See `web/comfyui/utils.js` for shared utilities
### Vue Composables Pattern
- Use composition API: `useXxxState(widget)`, return reactive refs and methods
- Guard restoration loops with flag: `let isRestoring = false`
- Build config from state: `const buildConfig = (): Config => { ... }`
## Architecture Patterns ## Architecture Patterns
### Service Layer ### Service Layer
- `ServiceRegistry` singleton for DI, services use `get_instance()` classmethod - Use `ServiceRegistry` singleton for dependency injection
- Services follow singleton pattern via `get_instance()` class method
- Separate scanners (discovery) from services (business logic) - Separate scanners (discovery) from services (business logic)
- Handlers in `py/routes/handlers/` are pure functions with deps as params - Handlers in `py/routes/handlers/` implement route logic
### Model Types & Routes ### Model Types
- `BaseModelService` base for LoRA, Checkpoint, Embedding - BaseModelService is abstract base for LoRA, Checkpoint, Embedding services
- `ModelScanner` for file discovery, hash deduplication - ModelScanner provides file discovery and hash-based deduplication
- `PersistentModelCache` (SQLite) for persistence - Persistent cache in SQLite via `PersistentModelCache`
- Route registrars: `ModelRouteRegistrar`, endpoints: `/loras/*`, `/checkpoints/*`, `/embeddings/*` - Metadata sync from CivitAI/CivArchive via `MetadataSyncService`
- WebSocket via `WebSocketManager` for real-time updates
### Routes & Handlers
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, etc.
- Handlers are pure functions taking dependencies as parameters
- Use `WebSocketManager` for real-time progress updates
- Return `aiohttp.web.json_response` or `web.Response`
### Recipe System ### Recipe System
- Base: `py/recipes/base.py`, Enrichment: `RecipeEnrichmentService` - Base metadata in `py/recipes/base.py`
- Parsers: `py/recipes/parsers/` - Enrichment adds model metadata: `RecipeEnrichmentService`
- Parsers for different formats in `py/recipes/parsers/`
## Important Notes ## Important Notes
- ALWAYS use English for comments (per copilot-instructions.md) - Always use English for comments (per copilot-instructions.md)
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json) - Dual mode: ComfyUI plugin (uses folder_paths) vs standalone (reads settings.json)
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"` - Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json` - Settings auto-saved in user directory or portable mode
- Symlinks require normalized paths - WebSocket broadcasts for real-time updates (downloads, scans)
- Symlink handling requires normalized paths
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
- Run `python scripts/sync_translation_keys.py` after UI string updates
## Frontend UI Architecture ## Frontend UI Architecture
### 1. Standalone Web UI This project has two distinct UI systems:
### 1. Standalone Lora Manager Web UI
- Location: `./static/` and `./templates/` - Location: `./static/` and `./templates/`
- Tech: Vanilla JS + CSS, served by standalone server - Purpose: Full-featured web application for managing LoRA models
- Tests via npm in root directory - Tech stack: Vanilla JS + CSS, served by the standalone server
- Development: Uses npm for frontend testing (`npm test`, `npm run test:watch`, etc.)
### 2. ComfyUI Custom Node Widgets ### 2. ComfyUI Custom Node Widgets
- Location: `./web/comfyui/` (Vanilla JS) + `./vue-widgets/` (Vue) - Location: `./web/comfyui/`
- Primary styles: `./web/comfyui/lm_styles.css` (NOT `./static/css/`) - Purpose: Widgets and UI logic that ComfyUI loads as custom node extensions
- Vue builds to `./web/comfyui/vue-widgets/`, typecheck via `vue-tsc` - Tech stack: Vanilla JS + Vue.js widgets (in `./vue-widgets/` and built to `./web/comfyui/vue-widgets/`)
- Widget styling: Primary styles in `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
- Development: No npm build step for these widgets (Vue widgets use build system)
### Widget Development Guidelines
- Use `app.registerExtension()` to register ComfyUI extensions (ComfyUI integration layer)
- Use `node.addDOMWidget()` for custom DOM widgets
- Widget styles should follow the patterns in `./web/comfyui/lm_styles.css`
- Selected state: `rgba(66, 153, 225, 0.3)` background, `rgba(66, 153, 225, 0.6)` border
- Hover state: `rgba(66, 153, 225, 0.2)` background
- Color palette matches the Lora Manager accent color (blue #4299e1)
- Use oklch() for color values when possible (defined in `./static/css/base.css`)
- Vue widget components are in `./vue-widgets/src/components/` and built to `./web/comfyui/vue-widgets/`
- When modifying widget styles, check `./web/comfyui/lm_styles.css` for consistency with other ComfyUI widgets

258
CLAUDE.md
View File

@@ -8,22 +8,17 @@ ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that
## Development Commands ## Development Commands
### Backend ### Backend Development
```bash ```bash
# Install dependencies
pip install -r requirements.txt pip install -r requirements.txt
# Install development dependencies (for testing)
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
# Run standalone server (port 8188 by default) # Run standalone server (port 8188 by default)
python standalone.py --port 8188 python standalone.py --port 8188
# Run all backend tests
pytest
# Run specific test file or function
pytest tests/test_recipes.py
pytest tests/test_recipes.py::test_function_name
# Run backend tests with coverage # Run backend tests with coverage
COVERAGE_FILE=coverage/backend/.coverage pytest \ COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py \ --cov=py \
@@ -32,158 +27,185 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov-report=html:coverage/backend/html \ --cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml \ --cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json --cov-report=json:coverage/backend/coverage.json
# Run specific test file
pytest tests/test_recipes.py
``` ```
### Frontend ### Frontend Development
There are three test suites run by `npm test`: vanilla JS tests (vitest at root) and Vue widget tests (`vue-widgets/` vitest).
```bash ```bash
# Install frontend dependencies
npm install npm install
cd vue-widgets && npm install && cd ..
# Run all frontend tests (JS + Vue) # Run frontend tests
npm test npm test
# Run only vanilla JS tests # Run frontend tests in watch mode
npm run test:js
# Run only Vue widget tests
npm run test:vue
# Watch mode (JS tests only)
npm run test:watch npm run test:watch
# Frontend coverage # Run frontend tests with coverage
npm run test:coverage npm run test:coverage
# Build Vue widgets (output to web/comfyui/vue-widgets/)
cd vue-widgets && npm run build
# Vue widget dev mode (watch + rebuild)
cd vue-widgets && npm run dev
# Typecheck Vue widgets
cd vue-widgets && npm run typecheck
``` ```
### Localization ### Localization
```bash ```bash
# Sync translation keys after UI string updates # Sync translation keys after UI string updates
python scripts/sync_translation_keys.py python scripts/sync_translation_keys.py
``` ```
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
## Architecture ## Architecture
### Dual Mode Operation ### Backend Structure (Python)
The system runs in two modes: **Core Entry Points:**
- **ComfyUI plugin mode**: Integrates with ComfyUI's PromptServer, uses `folder_paths` for model discovery - `__init__.py` - ComfyUI plugin entry point, registers nodes and routes
- **Standalone mode**: `standalone.py` mocks ComfyUI dependencies, reads paths from `settings.json` - `standalone.py` - Standalone server that mocks ComfyUI dependencies
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"` - `py/lora_manager.py` - Main LoraManager class that registers HTTP routes
### Backend (Python) **Service Layer** (`py/services/`):
- `ServiceRegistry` - Singleton service registry for dependency management
- `ModelServiceFactory` - Factory for creating model services (LoRA, Checkpoint, Embedding)
- Scanner services (`lora_scanner.py`, `checkpoint_scanner.py`, `embedding_scanner.py`) - Model file discovery and indexing
- `model_scanner.py` - Base scanner with hash-based deduplication and metadata extraction
- `persistent_model_cache.py` - SQLite-based cache for model metadata
- `metadata_sync_service.py` - Syncs metadata from CivitAI/CivArchive APIs
- `civitai_client.py` / `civarchive_client.py` - API clients for external services
- `downloader.py` / `download_manager.py` - Model download orchestration
- `recipe_scanner.py` - Recipe file management and image association
- `settings_manager.py` - Application settings with migration support
- `websocket_manager.py` - WebSocket broadcasting for real-time updates
- `use_cases/` - Business logic orchestration (auto-organize, bulk refresh, downloads)
**Entry points:** **Routes Layer** (`py/routes/`):
- `__init__.py` — ComfyUI plugin entry: registers nodes via `NODE_CLASS_MAPPINGS`, sets `WEB_DIRECTORY`, calls `LoraManager.add_routes()` - Route registrars organize endpoints by domain (models, recipes, previews, example images, updates)
- `standalone.py` — Standalone server: mocks `folder_paths` and node modules, starts aiohttp server - `handlers/` - Request handlers implementing business logic
- `py/lora_manager.py` — Main `LoraManager` class that registers all HTTP routes - Routes use aiohttp and integrate with ComfyUI's PromptServer
**Service layer** (`py/services/`): **Recipe System** (`py/recipes/`):
- `ServiceRegistry` singleton for dependency injection; services follow `get_instance()` singleton pattern - `base.py` - Base recipe metadata structure
- `BaseModelService` abstract base → `LoraService`, `CheckpointService`, `EmbeddingService` - `enrichment.py` - Enriches recipes with model metadata
- `ModelScanner` base → `LoraScanner`, `CheckpointScanner`, `EmbeddingScanner` for file discovery with hash-based deduplication - `merger.py` - Merges recipe data from multiple sources
- `PersistentModelCache` — SQLite-based metadata cache - `parsers/` - Parsers for different recipe formats (PNG, JSON, workflow)
- `MetadataSyncService` — Background sync from CivitAI/CivArchive APIs
- `SettingsManager` — Settings with schema migration support
- `WebSocketManager` — Real-time progress broadcasting
- `ModelServiceFactory` — Creates the right service for each model type
- Use cases in `py/services/use_cases/` orchestrate complex business logic (auto-organize, bulk refresh, downloads)
**Routes** (`py/routes/`): **Custom Nodes** (`py/nodes/`):
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc. - `lora_loader.py` - LoRA loader nodes with preset support
- Request handlers in `py/routes/handlers/` implement route logic - `save_image.py` - Enhanced save image with pattern-based filenames
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns - `trigger_word_toggle.py` - Toggle trigger words in prompts
- All routes use aiohttp, return `web.json_response` or `web.Response` - `lora_stacker.py` - Stack multiple LoRAs
- `prompt.py` - Prompt node with autocomplete
**Recipe system** (`py/recipes/`): - `wanvideo_lora_select.py` - WanVideo-specific LoRA selection
- `base.py` — Recipe metadata structure
- `enrichment.py` — Enriches recipes with model metadata
- `parsers/` — Parsers for PNG metadata, JSON, and workflow formats
**Custom nodes** (`py/nodes/`):
- Each node class has a `NAME` class attribute used as key in `NODE_CLASS_MAPPINGS`
- Standard ComfyUI node pattern: `INPUT_TYPES()` classmethod, `RETURN_TYPES`, `FUNCTION`
- All nodes registered in `__init__.py`
**Configuration** (`py/config.py`): **Configuration** (`py/config.py`):
- Manages folder paths for models, handles symlink mappings - Manages folder paths for models, checkpoints, embeddings
- Handles symlink mappings for complex directory structures
- Auto-saves paths to settings.json in ComfyUI mode - Auto-saves paths to settings.json in ComfyUI mode
### Frontend — Two Distinct UI Systems ### Frontend Structure (JavaScript)
#### 1. Standalone Manager Web UI **ComfyUI Widgets** (`web/comfyui/`):
- **Location:** `static/` (JS/CSS) and `templates/` (HTML) - Vanilla JavaScript ES modules extending ComfyUI's LiteGraph-based UI
- **Tech:** Vanilla JS + CSS, served by standalone server - `loras_widget.js` - Main LoRA selection widget with preview
- **Structure:** `static/js/core.js` (shared), `loras.js`, `checkpoints.js`, `embeddings.js`, `recipes.js`, `statistics.js` - `loras_widget_events.js` - Event handling for widget interactions
- **Tests:** `tests/frontend/**/*.test.js` (vitest + jsdom) - `autocomplete.js` - Autocomplete for trigger words and embeddings
- `preview_tooltip.js` - Preview tooltip for model cards
- `top_menu_extension.js` - Adds "Launch LoRA Manager" menu item
- `trigger_word_highlight.js` - Syntax highlighting for trigger words
- `utils.js` - Shared utilities and API helpers
#### 2. ComfyUI Custom Node Widgets **Widget Development:**
- **Vanilla JS widgets:** `web/comfyui/*.js` — ES modules extending ComfyUI's LiteGraph UI - Widgets use `app.registerExtension` and `getCustomWidgets` hooks
- `loras_widget.js` / `loras_widget_events.js` — Main LoRA selection widget - `node.addDOMWidget(name, type, element, options)` embeds HTML in nodes
- `autocomplete.js` — Trigger word and embedding autocomplete - See `docs/dom_widget_dev_guide.md` for complete DOMWidget development guide
- `preview_tooltip.js` — Model card preview tooltips
- `top_menu_extension.js` — "Launch LoRA Manager" menu item
- `utils.js` — Shared utilities and API helpers
- Widget styling in `web/comfyui/lm_styles.css` (NOT `static/css/`)
- **Vue widgets:** `vue-widgets/src/` → built to `web/comfyui/vue-widgets/`
- Vue 3 + TypeScript + PrimeVue + vue-i18n
- Vite build with CSS-injected-by-JS plugin
- Components: `LoraPoolWidget`, `LoraRandomizerWidget`, `LoraCyclerWidget`, `AutocompleteTextWidget`
- Auto-built on ComfyUI startup via `py/vue_widget_builder.py`
- Tests: `vue-widgets/tests/**/*.test.ts` (vitest)
**Widget registration pattern:** **Web Source** (`web-src/`):
- Widgets use `app.registerExtension()` and `getCustomWidgets` hooks - Modern frontend components (if migrating from static)
- `node.addDOMWidget(name, type, element, options)` embeds HTML in LiteGraph nodes - `components/` - Reusable UI components
- See `docs/dom_widget_dev_guide.md` for DOMWidget development guide - `styles/` - CSS styling
### Key Patterns
**Dual Mode Operation:**
- ComfyUI plugin mode: Integrates with ComfyUI's PromptServer, uses folder_paths
- Standalone mode: Mocks ComfyUI dependencies via `standalone.py`, reads paths from settings.json
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
**Settings Management:**
- Settings stored in user directory (via `platformdirs`) or portable mode (in repo)
- Migration system tracks settings schema version
- Template in `settings.json.example` defines defaults
**Model Scanning Flow:**
1. Scanner walks folder paths, computes file hashes
2. Hash-based deduplication prevents duplicate processing
3. Metadata extracted from safetensors headers
4. Persistent cache stores results in SQLite
5. Background sync fetches CivitAI/CivArchive metadata
6. WebSocket broadcasts updates to connected clients
**Recipe System:**
- Recipes store LoRA combinations with parameters
- Supports import from workflow JSON, PNG metadata
- Images associated with recipes via sibling file detection
- Enrichment adds model metadata for display
**Frontend-Backend Communication:**
- REST API for CRUD operations
- WebSocket for real-time progress updates (downloads, scans)
- API endpoints follow `/loras/*` pattern
## Code Style ## Code Style
**Python:** **Python:**
- PEP 8, 4-space indentation, English comments only - PEP 8 with 4-space indentation
- Use `from __future__ import annotations` for forward references - snake_case for files, functions, variables
- Use `TYPE_CHECKING` guard for type-checking-only imports - PascalCase for classes
- Type hints preferred
- English comments only (per copilot-instructions.md)
- Loggers via `logging.getLogger(__name__)` - Loggers via `logging.getLogger(__name__)`
- Custom exceptions in `py/services/errors.py`
- Async patterns: `async def` for I/O, `@pytest.mark.asyncio` for async tests
- Singleton pattern with class-level `asyncio.Lock` (see `ModelScanner.get_instance()`)
**JavaScript:** **JavaScript:**
- ES modules, camelCase functions/variables, PascalCase classes - ES modules with camelCase
- Widget files use `*_widget.js` suffix - Files use `*_widget.js` suffix for ComfyUI widgets
- Prefer vanilla JS for `web/comfyui/` widgets, avoid framework dependencies (except Vue widgets) - Prefer vanilla JS, avoid framework dependencies
## Testing ## Testing
**Backend (pytest):** **Backend Tests:**
- Config in `pytest.ini`: `--import-mode=importlib`, testpaths=`tests` - pytest with `--import-mode=importlib`
- Fixtures in `tests/conftest.py` handle ComfyUI dependency mocking - Test files: `tests/test_*.py`
- Markers: `@pytest.mark.asyncio`, `@pytest.mark.no_settings_dir_isolation` - Fixtures in `tests/conftest.py`
- Uses `tmp_path_factory` for directory isolation - Mock ComfyUI dependencies using standalone.py patterns
- Markers: `@pytest.mark.asyncio` for async tests, `@pytest.mark.no_settings_dir_isolation` for real paths
**Frontend (vitest):** **Frontend Tests:**
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom - Vitest with jsdom environment
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + @vue/test-utils - Test files: `tests/frontend/**/*.test.js`
- Setup in `tests/frontend/setup.js` - Setup in `tests/frontend/setup.js`
- Coverage via `npm run test:coverage`
## Key Integration Points ## Important Notes
- **Settings:** Stored in user directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`) **Settings Location:**
- **CivitAI/CivArchive:** API clients for metadata sync and model downloads; CivitAI API key in settings - ComfyUI mode: Auto-saves folder paths to user settings directory
- **Symlink handling:** Config scans symlinks to map virtual→physical paths; fingerprinting prevents redundant rescans - Standalone mode: Use `settings.json` (copy from `settings.json.example`)
- **WebSocket:** Broadcasts real-time progress for downloads, scans, and metadata sync - Portable mode: Set `"use_portable_settings": true` in settings.json
- **Model scanning flow:** Walk folders → compute hashes → deduplicate → extract safetensors metadata → cache in SQLite → background CivitAI sync → WebSocket broadcast
**API Integration:**
- CivitAI API key required for downloads (add to settings)
- CivArchive API used as fallback for deleted models
- Metadata archive database available for offline metadata
**Symlink Handling:**
- Config scans symlinks to map virtual paths to physical locations
- Preview validation uses normalized preview root paths
- Fingerprinting prevents redundant symlink rescans
**ComfyUI Node Development:**
- Nodes defined in `py/nodes/`, registered in `__init__.py`
- Frontend widgets in `web/comfyui/`, matched by node type
- Use `WEB_DIRECTORY = "./web/comfyui"` convention
**Recipe Image Association:**
- Recipes scan for sibling images in same directory
- Supports repair/migration of recipe image paths
- See `py/services/recipe_scanner.py` for implementation details

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,6 @@
try: # pragma: no cover - import fallback for pytest collection try: # pragma: no cover - import fallback for pytest collection
from .py.lora_manager import LoraManager from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
from .py.nodes.unet_loader import UNETLoaderLM
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
from .py.nodes.prompt import PromptLM from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM from .py.nodes.text import TextLM
@@ -29,12 +27,12 @@ except (
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
TextLM = importlib.import_module("py.nodes.text").TextLM TextLM = importlib.import_module("py.nodes.text").TextLM
LoraManager = importlib.import_module("py.lora_manager").LoraManager LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM LoraLoaderLM = importlib.import_module(
LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM "py.nodes.lora_loader"
CheckpointLoaderLM = importlib.import_module( ).LoraLoaderLM
"py.nodes.checkpoint_loader" LoraTextLoaderLM = importlib.import_module(
).CheckpointLoaderLM "py.nodes.lora_loader"
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM ).LoraTextLoaderLM
TriggerWordToggleLM = importlib.import_module( TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle" "py.nodes.trigger_word_toggle"
).TriggerWordToggleLM ).TriggerWordToggleLM
@@ -51,7 +49,9 @@ except (
LoraRandomizerLM = importlib.import_module( LoraRandomizerLM = importlib.import_module(
"py.nodes.lora_randomizer" "py.nodes.lora_randomizer"
).LoraRandomizerLM ).LoraRandomizerLM
LoraCyclerLM = importlib.import_module("py.nodes.lora_cycler").LoraCyclerLM LoraCyclerLM = importlib.import_module(
"py.nodes.lora_cycler"
).LoraCyclerLM
init_metadata_collector = importlib.import_module("py.metadata_collector").init init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
@@ -59,8 +59,6 @@ NODE_CLASS_MAPPINGS = {
TextLM.NAME: TextLM, TextLM.NAME: TextLM,
LoraLoaderLM.NAME: LoraLoaderLM, LoraLoaderLM.NAME: LoraLoaderLM,
LoraTextLoaderLM.NAME: LoraTextLoaderLM, LoraTextLoaderLM.NAME: LoraTextLoaderLM,
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
UNETLoaderLM.NAME: UNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM, TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM, LoraStackerLM.NAME: LoraStackerLM,
SaveImageLM.NAME: SaveImageLM, SaveImageLM.NAME: SaveImageLM,

View File

@@ -1,627 +0,0 @@
{
"specialThanks": [
"dispenser",
"EbonEagle",
"DanielMagPizza",
"Scott R"
],
"allSupporters": [
"Insomnia Art Designs",
"megakirbs",
"Brennok",
"wackop",
"2018cfh",
"Takkan",
"stone9k",
"$MetaSamsara",
"itismyelement",
"onesecondinosaur",
"Carl G.",
"Rosenthal",
"Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson",
"Greybush",
"Gooohokrbe",
"Ricky Carter",
"JongWon Han",
"OldBones",
"VantAI",
"runte3221",
"FreelancerZ",
"Julian V",
"Edgar Tejeda",
"Birdy",
"Liam MacDougal",
"Fraser Cross",
"Polymorphic Indeterminate",
"Marc Whiffen",
"Kiba",
"Jorge Hussni",
"Reno Lam",
"Skalabananen",
"esthe",
"sig",
"Christian Byrne",
"DM",
"Sen314",
"Estragon",
"J\\B/ 8r0wns0n",
"Snaggwort",
"Arlecchino Shion",
"ClockDaemon",
"KD",
"Omnidex",
"Tyler Trebuchon",
"Release Cabrakan",
"confiscated Zyra",
"SG",
"carozzz",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Adam Shaw",
"Tee Gee",
"Mark Corneglio",
"SarcasticHashtag",
"Anthony Rizzo",
"tarek helmi",
"Cosmosis",
"iamresist",
"RedrockVP",
"Wolffen",
"FloPro4Sho",
"James Todd",
"Steven Pfeiffer",
"Tim",
"Timmy",
"Johnny",
"Lisster",
"Michael Wong",
"Illrigger",
"whudunit",
"Tom Corrigan",
"JackieWang",
"fnkylove",
"Steven Owens",
"Yushio",
"Vik71it",
"lh qwe",
"Echo",
"Lilleman",
"Robert Stacey",
"PM",
"Todd Keck",
"Briton Heilbrun",
"Mozzel",
"Gingko Biloba",
"Felipe dos Santos",
"Penfore",
"BadassArabianMofo",
"Sterilized",
"Pascal Dahle",
"Markus",
"quarz",
"Greg",
"Douglas Gaspar",
"JSST",
"AlexDuKaNa",
"George",
"lmsupporter",
"Phil",
"Charles Blakemore",
"IamAyam",
"wfpearl",
"Rob Williams",
"Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble",
"Nazono_hito",
"Melville Parrish",
"daniel dove",
"Lustre",
"JW Sin",
"contrite831",
"Alex",
"bh",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"Graham Colehour",
"M Postkasse",
"Tomohiro Baba",
"David Ortega",
"ASLPro3D",
"Jacob Hoehler",
"FinalyFree",
"Weasyl",
"Lex Song",
"Cory Paza",
"Tak",
"Gonzalo Andre Allendes Lopez",
"Zach Gonser",
"Big Red",
"Jimmy Ledbetter",
"Luc Job",
"dl0901dm",
"Philip Hempel",
"corde",
"Nick Walker",
"Bishoujoker",
"conner",
"aai",
"Yaboi",
"Tori",
"wildnut",
"Princess Bright Eyes",
"Damon Cunliffe",
"CryptoTraderJK",
"Davaitamin",
"AbstractAss",
"ViperC",
"Aleksander Wujczyk",
"AM Kuro",
"jean jahren",
"Ran C",
"tedcor",
"S Sang",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.",
"Gordon Cole",
"yuxz69",
"MadSpin",
"andrew.tappan",
"dw",
"N/A",
"The Spawn",
"graysock",
"Greenmoustache",
"zounic",
"Gamalonia",
"fancypants",
"Vir",
"Joboshy",
"Digital",
"JaxMax",
"takyamtom",
"Bohemian Corporal",
"奚明 刘",
"Dan",
"Seth Christensen",
"Jwk0205",
"Bro Xie",
"Draven T",
"yer fey",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Some Guy Named Barry",
"jinxedx",
"Aquatic Coffee",
"Max Marklund",
"AELOX",
"Dankin",
"Nicfit23",
"Noora",
"ethanfel",
"wamekukyouzin",
"drum matthieu",
"Dogmaster",
"Matt Wenzel",
"Mattssn",
"Frank Nitty",
"John Saveas",
"Focuschannel",
"Christopher Michel",
"Serge Bekenkamp",
"LeoZero",
"Antonio Pontes",
"ApathyJones",
"nahinahi9",
"Anthony Faxlandez",
"Dustin Chen",
"dan",
"Blackfish95",
"Mouthlessman",
"Steam Steam",
"Paul Kroll",
"otaku fra",
"semicolon drainpipe",
"Thesharingbrother",
"Fotek Design",
"Bas Imagineer",
"Pat Hen",
"ResidentDeviant",
"Adam Taylor",
"JC",
"Weird_With_A_Beard",
"Prompt Pirate",
"Pozadine1",
"uwutismxd",
"Qarob",
"AIGooner",
"inbijiburu",
"decoy",
"Luc",
"ProtonPrince",
"DiffDuck",
"elu3199",
"Nick “Loadstone” D",
"Hasturkun",
"Jon Sandman",
"Ubivis",
"CloudValley",
"thesoftwaredruid",
"wundershark",
"mr_dinosaur",
"Tyrswood",
"linnfrey",
"zenobeus",
"Jackthemind",
"Stryker",
"Pkrsky",
"raf8osz",
"blikkies",
"Josef Lanzl",
"Griffin Dahlberg",
"준희 김",
"Error_Rule34_Not_found",
"Gerald Welly",
"Shock Shockor",
"Roslynd",
"Geolog",
"Goldwaters",
"Neco28",
"Zude",
"Cristian Vazquez",
"Kyler",
"Magic Noob",
"aRtFuL_DodGeR",
"X",
"DougPeterson",
"Jeff",
"Bruce",
"CrimsonDX",
"Kevin John Duck",
"Kevin Christopher",
"Ouro Boros",
"DarkSunset",
"dd",
"Billy Gladky",
"Probis",
"shrshpp",
"Dušan Ryban",
"ItsGeneralButtNaked",
"sjon kreutz",
"Nimess",
"John Statham",
"Youguang",
"Nihongasuki",
"Metryman55",
"andrewzpong",
"FrxzenSnxw",
"BossGame",
"Ray Wing",
"Ranzitho",
"Gus",
"地獄の禄",
"MJG",
"David LaVallee",
"ae",
"Tr4shP4nda",
"WRL_SPR",
"capn",
"Joseph",
"lrdchs",
"Mirko Katzula",
"dan",
"Piccio08",
"kumakichi",
"cppbel",
"starbugx",
"Moon Knight",
"몽타주",
"Kland",
"Hailshem",
"ryoma",
"John Martin",
"Chris",
"Brian M",
"Nerezza",
"sanborondon",
"moranqianlong",
"Taylor Funk",
"aezin",
"Thought2Form",
"jcay015",
"Kevin Picco",
"Erik Lopez",
"Mateo Curić",
"Haru Yotu",
"Eris3D",
"m",
"Pierce McBride",
"Joshua Gray",
"Mikko Hemilä",
"Matura Arbeit",
"Jamie Ogletree",
"TBitz33",
"Emil Bernhoff",
"a _",
"SendingRavens",
"James Coleman",
"Martial",
"battu",
"Emil Andersson",
"Chad Idk",
"Michael Docherty",
"Yuji Kaneko",
"elitassj",
"Jacob Winter",
"Jordan Shaw",
"Sam",
"Rops Alot",
"SRDB",
"g unit",
"Ace Ventura",
"David",
"Meilo",
"Pen Bouryoung",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"momokai",
"Janik",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken",
"Inversity",
"Crocket",
"AIVORY3D",
"epicgamer0020690",
"Joshua Porrata",
"Cruel",
"keemun",
"SuBu",
"RedPIXel",
"MRBlack",
"Kevinj",
"Wind",
"Nexus",
"Mitchell Robson",
"Ramneek“Guy”Ashok",
"squid_actually",
"Nat_20",
"Kiyoe",
"Edward Weeks",
"kyoumei",
"RadStorm04",
"JohnDoe42054",
"BillyHill",
"humptynutz",
"emyth",
"michael.isaza",
"Kalnei",
"chriphost",
"KitKatM",
"socrasteeze",
"ResidentDeviant",
"Scott",
"gzmzmvp",
"Welkor",
"hayden",
"Richard",
"ahoystan",
"Leland Saunders",
"Andrew",
"Bob Barker",
"Robert Wegemund",
"Littlehuggy",
"Gregory Kozhemiak",
"mrjuan",
"Aeternyx",
"Brian Buie",
"YOU SINWOO",
"Sadlip",
"ja s",
"Eric Whitney",
"Doug Mason",
"Joey Callahan",
"Ivan Tadic",
"y2Rxy7FdXzWo",
"Jeremy Townsend",
"Mike Simone",
"Sean voets",
"Owen Gwosdz",
"Morgandel",
"Thomas Wanner",
"Kyron Mahan",
"Theerat Jiramate",
"Noah",
"Jacob McDaniel",
"kevin stoddard",
"Sloan Steddy",
"Jack Dole",
"Ezokewn",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"Michael Anthony Scott",
"Atilla Berke Pekduyar",
"Maso",
"Nathan",
"Decx _",
"Kevin Wallace",
"Matheus Couto",
"Paul Hartsuyker",
"ChicRic",
"mercur",
"J C",
"Distortik",
"Yves Poezevara",
"Teriak47",
"Just me",
"Raf Stahelin",
"Вячеслав Маринин",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
"Zertens",
"NOHOW",
"Apo",
"nekotxt",
"choowkee",
"Clusters",
"ibrahim",
"Highlandrise",
"philcoraz",
"mztn",
"ImagineerNL",
"MrAcrtosSursus",
"al300680",
"pixl",
"Robin",
"chahknoir",
"Marcus thronico",
"nd",
"keno94d",
"James Melzer",
"Bartleby",
"Renvertere",
"Rahuy",
"Hermann003",
"D",
"Foolish",
"RevyHiep",
"Captain_Swag",
"obkircher",
"Tree Tagger",
"gwyar",
"D",
"edgecase",
"Neoxena",
"mrmhalo",
"dg",
"Whitepinetrader",
"Maarten Harms",
"OrganicArtifact",
"四糸凜音",
"MudkipMedkitz",
"Israel",
"deanbrian",
"POPPIN",
"Muratoraccio",
"SelfishMedic",
"Ginnie",
"Alex Wortman",
"Cody",
"adderleighn",
"Raku",
"smart.edge5178",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Menard",
"Skyfire83",
"Adam Rinehart",
"D",
"Pitpe11",
"TheD1rtyD03",
"EnragedAntelope",
"moonpetal",
"SomeDude",
"g9p0o",
"nanana",
"TheHolySheep",
"Monte Won",
"SpringBootisTrash",
"carsten",
"ikok",
"Buecyb99",
"4IXplr0r3r",
"Coeur+de+cochon",
"David Schenck",
"han b",
"Nico",
"Wolfe7D1",
"Banana Joe",
"_ G3n",
"Donovan Jenkins",
"Ink Temptation",
"edk",
"Michael Eid",
"beersandbacon",
"Maximilian Pyko",
"Invis",
"Kalli Core",
"Justin Houston",
"james",
"elleshar666",
"OrochiNights",
"Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo",
"Seraphy",
"雨の心 落",
"AllTimeNoobie",
"jumpd",
"John C",
"Kauffy",
"Rim",
"Dismem",
"EpicElric",
"John J Linehan",
"Xan Dionysus",
"Nathan lee",
"Mewtora",
"Elliot E",
"Middo",
"Forbidden Atelier",
"Edward Kennedy",
"Justin Blaylock",
"Adictedtohumping",
"Devil Lude",
"Nick Kage",
"Towelie",
"Vane Holzer",
"psytrax",
"Cyrus Fett",
"Jean-françois SEMA",
"Kurt",
"hexxish",
"giani kidd",
"CptNeo",
"notedfakes",
"Chase Kwon",
"Goober719",
"Eric Ketchum",
"Chad Barnes",
"NICHOLAS BAXLEY",
"Michael Scott",
"James Ming",
"vanditking",
"kripitonga",
"Rizzi",
"nimin",
"OMAR LUCIANO",
"Jo+Example",
"BrentBertram",
"eumelzocker",
"dxjaymz",
"L C",
"Dude"
],
"totalCount": 620
}

View File

@@ -1,6 +1,9 @@
## Overview ## Overview
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can: The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com).
It also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
With this extension, you can:
✅ Instantly see which models are already present in your local library ✅ Instantly see which models are already present in your local library
✅ Download new models with a single click ✅ Download new models with a single click
@@ -8,20 +11,21 @@ The **LoRA Manager Civitai Extension** is a Browser extension designed to work s
✅ Keep your downloaded models automatically organized according to your custom settings ✅ Keep your downloaded models automatically organized according to your custom settings
![Civitai Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-models-page.png) ![Civitai Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-models-page.png)
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
![CivArchive Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civarchive-models-page.png) ![CivArchive Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civarchive-models-page.png)
--- ---
## Why Supporter Access? ## Why Are All Features for Supporters Only?
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time. I love building tools for the Stable Diffusion and ComfyUI communities, and LoRA Manager is a passion project that I've poured countless hours into. When I created this companion extension, my hope was to offer its core features for free, as a thank-you to all of you.
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone. Unfortunately, I've reached a point where I need to be realistic. The level of support from the free model has been far lower than what's needed to justify the continuous development and maintenance for both projects. It was a difficult decision, but I've chosen to make the extension's features exclusive to supporters.
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️ This change is crucial for me to be able to continue dedicating my time to improving the free and open-source LoRA Manager, which I'm committed to keeping available for everyone.
Your support does more than just unlock a few features—it allows me to keep innovating and ensures the core LoRA Manager project thrives. I'm incredibly grateful for your understanding and any support you can offer. ❤️
(_For those who previously supported me on Ko-fi with a one-time donation, I'll be sending out license keys individually as a thank-you._)
--- ---
@@ -86,27 +90,20 @@ Clicking the download button adds the corresponding model version to the downloa
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library. On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button: When switching to a specific version by clicking a version button:
- The new **dedicated download button** directly triggers download via **LoRA Manager** - Clicking the download button will open a dropdown:
- The **original download button** remains unchanged for standard browser downloads - Download via **LoRA Manager**
- Download via **Original Download** (browser download)
You can check **Remember my choice** to set your preferred default. You can change this setting anytime in the extension's settings.
![Civitai Model Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-model-page.png) ![Civitai Model Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-model-page.png)
### Hide Models Already in Library (Beta) ### Resources on Image Pages (2025-08-05) — now shows in-library indicators for image resources. Import image as recipe coming soon!
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
![Civitai Image Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-image-page.jpg) ![Civitai Image Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-image-page.jpg)
[![alt](url)](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
--- ---
## Model Download Location & LoRA Manager Settings ## Model Download Location & LoRA Manager Settings
@@ -173,11 +170,11 @@ _Thanks to user **Temikus** for sharing this solution!_
The extension will evolve alongside **LoRA Manager** improvements. Planned features include: The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
- [x] Support for **additional model types** (e.g., embeddings) - [x] Support for **additional model types** (e.g., embeddings)
- [x] One-click **Recipe Import** - [ ] One-click **Recipe Import**
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page - [x] Display of in-library status for all resources in the **Resources Used** section of the image page
- [x] One-click **Auto-organize Models** - [x] One-click **Auto-organize Models**
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
**Stay tuned — and thank you for your support!** **Stay tuned — and thank you for your support!**
--- ---

View File

@@ -1,170 +0,0 @@
# Recipe Batch Import Feature Requirements
## Overview
Enable users to import multiple images as recipes in a single operation, rather than processing them individually. This feature addresses the need for efficient bulk recipe creation from existing image collections.
## User Stories
### US-1: Directory Batch Import
As a user with a folder of reference images or workflow screenshots, I want to import all images from a directory at once so that I don't have to import them one by one.
**Acceptance Criteria:**
- User can specify a local directory path containing images
- System discovers all supported image files in the directory
- Each image is analyzed for metadata and converted to a recipe
- Results show which images succeeded, failed, or were skipped
### US-2: URL Batch Import
As a user with a list of image URLs (e.g., from Civitai or other sources), I want to import multiple images by URL in one operation.
**Acceptance Criteria:**
- User can provide multiple image URLs (one per line or as a list)
- System downloads and processes each image
- URL-specific metadata (like Civitai info) is preserved when available
- Failed URLs are reported with clear error messages
### US-3: Concurrent Processing Control
As a user with varying system resources, I want to control how many images are processed simultaneously to balance speed and system load.
**Acceptance Criteria:**
- User can configure the number of concurrent operations (1-10)
- System provides sensible defaults based on common hardware configurations
- Processing respects the concurrency limit to prevent resource exhaustion
### US-4: Import Results Summary
As a user performing a batch import, I want to see a clear summary of the operation results so I understand what succeeded and what needs attention.
**Acceptance Criteria:**
- Total count of images processed is displayed
- Number of successfully imported recipes is shown
- Number of failed imports with error details is provided
- Number of skipped images (no metadata) is indicated
- Results can be exported or saved for reference
### US-5: Progress Visibility
As a user importing a large batch, I want to see the progress of the operation so I know it's working and can estimate completion time.
**Acceptance Criteria:**
- Progress indicator shows current status (e.g., "Processing image 5 of 50")
- Real-time updates as each image completes
- Ability to view partial results before completion
- Clear indication when the operation is finished
## Functional Requirements
### FR-1: Image Discovery
The system shall discover image files in a specified directory recursively or non-recursively based on user preference.
**Supported formats:** JPG, JPEG, PNG, WebP, GIF, BMP
### FR-2: Metadata Extraction
For each image, the system shall:
- Extract EXIF metadata if present
- Parse embedded workflow data (ComfyUI PNG metadata)
- Fetch external metadata for known URL patterns (e.g., Civitai)
- Generate recipes from extracted information
### FR-3: Concurrent Processing
The system shall support concurrent processing of multiple images with:
- Configurable concurrency limit (default: 3)
- Resource-aware execution
- Graceful handling of individual failures without stopping the batch
### FR-4: Error Handling
The system shall handle various error conditions:
- Invalid directory paths
- Inaccessible files
- Network errors for URL imports
- Images without extractable metadata
- Malformed or corrupted image files
### FR-5: Recipe Persistence
Successfully analyzed images shall be persisted as recipes with:
- Extracted generation parameters
- Preview image association
- Tags and metadata
- Source information (file path or URL)
## Non-Functional Requirements
### NFR-1: Performance
- Batch operations should complete in reasonable time (< 5 seconds per image on average)
- UI should remain responsive during batch operations
- Memory usage should scale gracefully with batch size
### NFR-2: Scalability
- Support batches of 1-1000 images
- Handle mixed success/failure scenarios gracefully
- No hard limits on concurrent operations (configurable)
### NFR-3: Usability
- Clear error messages for common failure cases
- Intuitive UI for configuring import options
- Accessible from the main Recipes interface
### NFR-4: Reliability
- Failed individual imports should not crash the entire batch
- Partial results should be preserved on unexpected termination
- All operations should be idempotent (re-importing same image doesn't create duplicates)
## API Requirements
### Batch Import Endpoints
The system should expose endpoints for:
1. **Directory Import**
- Accept directory path and configuration options
- Return operation ID for status tracking
- Async or sync operation support
2. **URL Import**
- Accept list of URLs and configuration options
- Support URL validation before processing
- Return operation ID for status tracking
3. **Status/Progress**
- Query operation status by ID
- Get current progress and partial results
- Retrieve final results after completion
## UI/UX Requirements
### UIR-1: Entry Point
Batch import should be accessible from the Recipes page via a clearly labeled button in the toolbar.
### UIR-2: Import Modal
A modal dialog should provide:
- Tab or section for Directory import
- Tab or section for URL import
- Configuration options (concurrency, options)
- Start/Stop controls
- Results display area
### UIR-3: Results Display
Results should be presented with:
- Summary statistics (total, success, failed, skipped)
- Expandable details for each category
- Export or copy functionality for results
- Clear visual distinction between success/failure/skip
## Future Considerations
- **Scheduled Imports**: Ability to schedule batch imports for later execution
- **Import Templates**: Save import configurations for reuse
- **Cloud Storage**: Import from cloud storage services (Google Drive, Dropbox)
- **Duplicate Detection**: Advanced duplicate detection based on image hash
- **Tag Suggestions**: AI-powered tag suggestions for imported recipes
- **Batch Editing**: Apply tags or organization to multiple imported recipes at once
## Dependencies
- Recipe analysis service (metadata extraction)
- Recipe persistence service (storage)
- Image download capability (for URL imports)
- Recipe scanner (for refresh after import)
- Civitai client (for enhanced URL metadata)
---
*Document Version: 1.0*
*Status: Requirements Definition*

View File

@@ -1,363 +0,0 @@
# metadata.json Schema Documentation
This document defines the complete schema for `.metadata.json` files used by Lora Manager. These sidecar files store model metadata alongside model files (LoRA, Checkpoint, Embedding).
## Overview
- **File naming**: `<model_name>.metadata.json` (e.g., `my_lora.safetensors``my_lora.metadata.json`)
- **Format**: JSON with UTF-8 encoding
- **Purpose**: Store model metadata, tags, descriptions, preview images, and Civitai/CivArchive integration data
- **Extensibility**: Unknown fields are preserved via `_unknown_fields` mechanism for forward compatibility
---
## Base Fields (All Model Types)
These fields are present in all model metadata files.
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `file_name` | string | ✅ Yes | ✅ Yes | Filename without extension (e.g., `"my_lora"`) |
| `model_name` | string | ✅ Yes | ❌ No | Display name of the model. **Default**: `file_name` if no other source |
| `file_path` | string | ✅ Yes | ✅ Yes | Full absolute path to the model file (normalized with `/` separators) |
| `size` | integer | ✅ Yes | ❌ No | File size in bytes. **Set at**: Initial scan or download completion. Does not change thereafter. |
| `modified` | float | ✅ Yes | ❌ No | **Import timestamp** — Unix timestamp when the model was first imported/added to the system. Used for "Date Added" sorting. Does not change after initial creation. |
| `sha256` | string | ⚠️ Conditional | ✅ Yes | SHA256 hash of the model file (lowercase). **LoRA**: Required. **Checkpoint**: May be empty when `hash_status="pending"` (lazy hash calculation) |
| `base_model` | string | ❌ No | ❌ No | Base model type. **Examples**: `"SD 1.5"`, `"SDXL 1.0"`, `"SDXL Lightning"`, `"Flux.1 D"`, `"Flux.1 S"`, `"Flux.1 Krea"`, `"Illustrious"`, `"Pony"`, `"AuraFlow"`, `"Kolors"`, `"ZImageTurbo"`, `"Wan Video"`, etc. **Default**: `"Unknown"` or `""` |
| `preview_url` | string | ❌ No | ✅ Yes | Path to preview image file |
| `preview_nsfw_level` | integer | ❌ No | ❌ No | NSFW level using **bitmask values** from Civitai: `1` (PG), `2` (PG13), `4` (R), `8` (X), `16` (XXX), `32` (Blocked). **Default**: `0` (none) |
| `notes` | string | ❌ No | ❌ No | User-defined notes |
| `from_civitai` | boolean | ❌ No (default: `true`) | ❌ No | Whether the model originated from Civitai |
| `civitai` | object | ❌ No | ⚠️ Partial | Civitai/CivArchive API data and user-defined fields |
| `tags` | array[string] | ❌ No | ⚠️ Partial | Model tags (merged from API and user input) |
| `modelDescription` | string | ❌ No | ⚠️ Partial | Full model description (from API or user) |
| `civitai_deleted` | boolean | ❌ No (default: `false`) | ❌ No | Whether the model was deleted from Civitai |
| `favorite` | boolean | ❌ No (default: `false`) | ❌ No | Whether the model is marked as favorite |
| `exclude` | boolean | ❌ No (default: `false`) | ❌ No | Whether to exclude from cache/scanning. User can set from `false` to `true` (currently no UI to revert) |
| `db_checked` | boolean | ❌ No (default: `false`) | ❌ No | Whether checked against archive database |
| `skip_metadata_refresh` | boolean | ❌ No (default: `false`) | ❌ No | Skip this model during bulk metadata refresh |
| `metadata_source` | string\|null | ❌ No | ✅ Yes | Last provider that supplied metadata (see below) |
| `last_checked_at` | float | ❌ No (default: `0`) | ✅ Yes | Unix timestamp of last metadata check |
| `hash_status` | string | ❌ No (default: `"completed"`) | ✅ Yes | Hash calculation status: `"pending"`, `"calculating"`, `"completed"`, `"failed"` |
---
## Model-Specific Fields
### LoRA Models
LoRA models do not have a `model_type` field in metadata.json. The type is inferred from context or `civitai.type` (e.g., `"LoRA"`, `"LoCon"`, `"DoRA"`).
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `usage_tips` | string (JSON) | ❌ No (default: `"{}"`) | ❌ No | JSON string containing recommended usage parameters |
**`usage_tips` JSON structure:**
```json
{
"strength_min": 0.3,
"strength_max": 0.8,
"strength_range": "0.3-0.8",
"strength": 0.6,
"clip_strength": 0.5,
"clip_skip": 2
}
```
| Key | Type | Description |
|-----|------|-------------|
| `strength_min` | number | Minimum recommended model strength |
| `strength_max` | number | Maximum recommended model strength |
| `strength_range` | string | Human-readable strength range |
| `strength` | number | Single recommended strength value |
| `clip_strength` | number | Recommended CLIP/embedding strength |
| `clip_skip` | integer | Recommended CLIP skip value |
---
### Checkpoint Models
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `model_type` | string | ❌ No (default: `"checkpoint"`) | ❌ No | Model type: `"checkpoint"`, `"diffusion_model"` |
---
### Embedding Models
| Field | Type | Required | Auto-Updated | Description |
|-------|------|----------|--------------|-------------|
| `model_type` | string | ❌ No (default: `"embedding"`) | ❌ No | Model type: `"embedding"` |
---
## The `civitai` Field Structure
The `civitai` object stores the complete Civitai/CivArchive API response. Lora Manager preserves all fields from the API for future compatibility and extracts specific fields for use in the application.
### Version-Level Fields (Civitai API)
**Fields Used by Lora Manager:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Version ID |
| `modelId` | integer | Parent model ID |
| `name` | string | Version name (e.g., `"v1.0"`, `"v2.0-pruned"`) |
| `nsfwLevel` | integer | NSFW level (bitmask: 1=PG, 2=PG13, 4=R, 8=X, 16=XXX, 32=Blocked) |
| `baseModel` | string | Base model (e.g., `"SDXL 1.0"`, `"Flux.1 D"`, `"Illustrious"`, `"Pony"`) |
| `trainedWords` | array[string] | **Trigger words** for the model |
| `type` | string | Model type (`"LoRA"`, `"Checkpoint"`, `"TextualInversion"`) |
| `earlyAccessEndsAt` | string\|null | Early access end date (used for update notifications) |
| `description` | string | Version description (HTML) |
| `model` | object | Parent model object (see Model-Level Fields below) |
| `creator` | object | Creator information (see Creator Fields below) |
| `files` | array[object] | File list with hashes, sizes, download URLs (used for metadata extraction) |
| `images` | array[object] | Image list with metadata, prompts, NSFW levels (used for preview/examples) |
**Fields Stored but Not Currently Used:**
| Field | Type | Description |
|-------|------|-------------|
| `createdAt` | string (ISO 8601) | Creation timestamp |
| `updatedAt` | string (ISO 8601) | Last update timestamp |
| `status` | string | Version status (e.g., `"Published"`, `"Draft"`) |
| `publishedAt` | string (ISO 8601) | Publication timestamp |
| `baseModelType` | string | Base model type (e.g., `"Standard"`, `"Inpaint"`, `"Refiner"`) |
| `earlyAccessConfig` | object | Early access configuration |
| `uploadType` | string | Upload type (`"Created"`, `"FineTuned"`, etc.) |
| `usageControl` | string | Usage control setting |
| `air` | string | Artifact ID (URN format: `urn:air:sdxl:lora:civitai:122359@135867`) |
| `stats` | object | Download count, ratings, thumbs up count |
| `videos` | array[object] | Video list |
| `downloadUrl` | string | Direct download URL |
| `trainingStatus` | string\|null | Training status (for on-site training) |
| `trainingDetails` | object\|null | Training configuration |
### Model-Level Fields (`civitai.model.*`)
**Fields Used by Lora Manager:**
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Model name |
| `type` | string | Model type (`"LoRA"`, `"Checkpoint"`, `"TextualInversion"`) |
| `description` | string | Model description (HTML, used for `modelDescription`) |
| `tags` | array[string] | Model tags (used for `tags` field) |
| `allowNoCredit` | boolean | License: allow use without credit |
| `allowCommercialUse` | array[string] | License: allowed commercial uses. **Values**: `"Image"` (sell generated images), `"Video"` (sell generated videos), `"RentCivit"` (rent on Civitai), `"Rent"` (rent elsewhere) |
| `allowDerivatives` | boolean | License: allow derivatives |
| `allowDifferentLicense` | boolean | License: allow different license |
**Fields Stored but Not Currently Used:**
| Field | Type | Description |
|-------|------|-------------|
| `nsfw` | boolean | Model NSFW flag |
| `poi` | boolean | Person of Interest flag |
### Creator Fields (`civitai.creator.*`)
Both fields are used by Lora Manager:
| Field | Type | Description |
|-------|------|-------------|
| `username` | string | Creator username (used for author display and search) |
| `image` | string | Creator avatar URL (used for display) |
### Model Type Field (Top-Level, Outside `civitai`)
| Field | Type | Values | Description |
|-------|------|--------|-------------|
| `model_type` | string | `"checkpoint"`, `"diffusion_model"`, `"embedding"` | Stored in metadata.json for Checkpoint and Embedding models. **Note**: LoRA models do not have this field; type is inferred from `civitai.type` or context. |
### User-Defined Fields (Within `civitai`)
For models not from Civitai or user-added data:
| Field | Type | Description |
|-------|------|-------------|
| `trainedWords` | array[string] | **Trigger words** — manually added by user |
| `customImages` | array[object] | Custom example images added by user |
### customImages Structure
Each custom image entry has the following structure:
```json
{
"url": "",
"id": "short_id",
"nsfwLevel": 0,
"width": 832,
"height": 1216,
"type": "image",
"meta": {
"prompt": "...",
"negativePrompt": "...",
"steps": 20,
"cfgScale": 7,
"seed": 123456
},
"hasMeta": true,
"hasPositivePrompt": true
}
```
| Field | Type | Description |
|-------|------|-------------|
| `url` | string | Empty for local custom images |
| `id` | string | Short ID or filename |
| `nsfwLevel` | integer | NSFW level (bitmask) |
| `width` | integer | Image width in pixels |
| `height` | integer | Image height in pixels |
| `type` | string | `"image"` or `"video"` |
| `meta` | object\|null | Generation metadata (prompt, seed, etc.) extracted from image |
| `hasMeta` | boolean | Whether metadata is available |
| `hasPositivePrompt` | boolean | Whether a positive prompt is available |
### Minimal Non-Civitai Example
```json
{
"civitai": {
"trainedWords": ["my_trigger_word"]
}
}
```
### Non-Civitai Example Without Trigger Words
```json
{
"civitai": {}
}
```
### Example: User-Added Custom Images
```json
{
"civitai": {
"trainedWords": ["custom_style"],
"customImages": [
{
"url": "",
"id": "example_1",
"nsfwLevel": 0,
"width": 832,
"height": 1216,
"type": "image",
"meta": {
"prompt": "example prompt",
"seed": 12345
},
"hasMeta": true,
"hasPositivePrompt": true
}
]
}
}
```
---
## Metadata Source Values
The `metadata_source` field indicates which provider last updated the metadata:
| Value | Source |
|-------|--------|
| `"civitai_api"` | Civitai API |
| `"civarchive"` | CivArchive API |
| `"archive_db"` | Metadata Archive Database |
| `null` | No external source (user-defined only) |
---
## Auto-Update Behavior
### Fields Updated During Scanning
These fields are automatically synchronized with the filesystem:
- `file_name` — Updated if actual filename differs
- `file_path` — Normalized and updated if path changes
- `preview_url` — Updated if preview file is moved/removed
- `sha256` — Updated during hash calculation (when `hash_status="pending"`)
- `hash_status` — Updated during hash calculation
- `last_checked_at` — Timestamp of scan
- `metadata_source` — Set based on metadata provider
### Fields Set Once (Immutable After Import)
These fields are set when the model is first imported/scanned and **never change** thereafter:
- `modified` — Import timestamp (used for "Date Added" sorting)
- `size` — File size at time of import/download
### User-Editable Fields
These fields can be edited by users at any time through the Lora Manager UI or by manually editing the metadata.json file:
- `model_name` — Display name
- `tags` — Model tags
- `modelDescription` — Model description
- `notes` — User notes
- `favorite` — Favorite flag
- `exclude` — Exclude from scanning (user can set `false``true`, currently no UI to revert)
- `skip_metadata_refresh` — Skip during bulk refresh
- `civitai.trainedWords` — Trigger words
- `civitai.customImages` — Custom example images
- `usage_tips` — Usage recommendations (LoRA only)
---
## Field Reference by Behavior
### Required Fields (Must Always Exist)
- `file_name`
- `model_name` (defaults to `file_name` if not provided)
- `file_path`
- `size`
- `modified`
- `sha256` (LoRA: always required; Checkpoint: may be empty when `hash_status="pending"`)
### Optional Fields with Defaults
| Field | Default |
|-------|---------|
| `base_model` | `"Unknown"` or `""` |
| `preview_nsfw_level` | `0` |
| `from_civitai` | `true` |
| `civitai` | `{}` |
| `tags` | `[]` |
| `modelDescription` | `""` |
| `notes` | `""` |
| `civitai_deleted` | `false` |
| `favorite` | `false` |
| `exclude` | `false` |
| `db_checked` | `false` |
| `skip_metadata_refresh` | `false` |
| `metadata_source` | `null` |
| `last_checked_at` | `0` |
| `hash_status` | `"completed"` |
| `usage_tips` | `"{}"` (LoRA only) |
| `model_type` | `"checkpoint"` or `"embedding"` (not present in LoRA models) |
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-03 | Initial schema documentation |
---
## See Also
- [JSON Schema Definition](../.specs/metadata.schema.json) — Formal JSON Schema for validation

View File

@@ -1,678 +0,0 @@
# Backend Testing Improvement Plan
**Status:** Phase 4 Complete ✅
**Created:** 2026-02-11
**Updated:** 2026-02-11
**Priority:** P0 - Critical
---
## Executive Summary
This document outlines a comprehensive plan to improve the quality, coverage, and maintainability of the LoRa Manager backend test suite. Recent critical bugs (_handle_download_task_done and get_status methods missing) were not caught by existing tests, highlighting significant gaps in the testing strategy.
## Current State Assessment
### Test Statistics
- **Total Python Test Files:** 80+
- **Total JavaScript Test Files:** 29
- **Test Lines of Code:** ~15,000
- **Current Pass Rate:** 100% (but missing critical edge cases)
### Key Findings
1. **Coverage Gaps:** Critical modules have no direct tests
2. **Mocking Issues:** Over-mocking hides real bugs
3. **Integration Deficit:** Missing end-to-end tests
4. **Async Inconsistency:** Multiple patterns for async tests
5. **Maintenance Burden:** Large, complex test files with duplication
---
## Phase 2 Completion Summary (2026-02-11)
### Completed Items
1. **Integration Test Framework**
- Created `tests/integration/` directory structure
- Added `tests/integration/conftest.py` with shared fixtures
- Added `tests/integration/__init__.py` for package organization
2. **Download Flow Integration Tests**
- Created `tests/integration/test_download_flow.py` with 7 tests
- Tests cover:
- Download with mocked network (2 tests)
- Progress broadcast verification (1 test)
- Error handling (1 test)
- Cancellation flow (1 test)
- Concurrent download management (1 test)
- Route endpoint validation (1 test)
3. **Recipe Flow Integration Tests**
- Created `tests/integration/test_recipe_flow.py` with 9 tests
- Tests cover:
- Recipe save and retrieve flow (1 test)
- Recipe update flow (1 test)
- Recipe delete flow (1 test)
- Recipe model extraction (1 test)
- Generation parameters handling (1 test)
- Concurrent recipe reads (1 test)
- Concurrent read/write operations (1 test)
- Recipe list endpoint (1 test)
- Recipe metadata parsing (1 test)
4. **ModelLifecycleService Coverage**
- Added 12 new tests to `tests/services/test_model_lifecycle_service.py`
- Tests cover:
- `exclude_model` functionality (3 tests)
- `bulk_delete_models` functionality (2 tests)
- Error path tests (5 tests)
- `_extract_model_id_from_payload` utility (3 tests)
- Total: 18 tests (up from 6)
5. **PersistentRecipeCache Concurrent Access**
- Added 5 new concurrent access tests to `tests/test_persistent_recipe_cache.py`
- Tests cover:
- Concurrent reads without corruption (1 test)
- Concurrent write and read operations (1 test)
- Concurrent updates to same recipe (1 test)
- Schema initialization thread safety (1 test)
- Concurrent save and remove operations (1 test)
- Total: 17 tests (up from 12)
### Test Results
- **Integration Tests:** 16/16 passing
- **ModelLifecycleService Tests:** 18/18 passing
- **PersistentRecipeCache Tests:** 17/17 passing
- **Total New Tests Added:** 28 tests
---
## Phase 1 Completion Summary (2026-02-11)
### Completed Items
1. **pytest-asyncio Integration**
- Added `pytest-asyncio>=0.21.0` to `requirements-dev.txt`
- Updated `pytest.ini` with `asyncio_mode = auto` and `asyncio_default_fixture_loop_scope = function`
- Removed custom `pytest_pyfunc_call` handler from `tests/conftest.py`
- Added `@pytest.mark.asyncio` decorator to 21 async test functions in `tests/services/test_download_manager.py`
2. **Error Path Tests**
- Created `tests/services/test_downloader_error_paths.py` with 19 new tests
- Tests cover:
- DownloadStreamControl state management (6 tests)
- Downloader configuration and initialization (4 tests)
- DownloadProgress dataclass (1 test)
- Custom exceptions (2 tests)
- Authentication headers (3 tests)
- Session management (3 tests)
3. **Test Results**
- All 45 tests pass (26 in test_download_manager.py + 19 in test_downloader_error_paths.py)
- No regressions introduced
### Notes
- Over-mocking fix in `test_download_manager.py` deferred to Phase 2 as it requires significant refactoring
- Error path tests focus on unit-level testing of downloader components rather than complex integration scenarios
---
## Phase 1: Critical Fixes (P0) - Week 1-2
### 1.1 Fix Over-Mocking Issues
**Problem:** Tests mock the methods they purport to test, hiding real bugs.
**Affected Files:**
- `tests/services/test_download_manager.py` - Mocks `_execute_download`
- `tests/utils/test_example_images_download_manager_unit.py` - Mocks callbacks
- `tests/routes/test_base_model_routes_smoke.py` - Uses fake service stubs
**Actions:**
1. Refactor `test_download_manager.py` to test actual download logic
2. Replace method-level mocks with dependency injection
3. Add integration tests that verify real behavior
**Example Fix:**
```python
# BEFORE (Bad - mocks method under test)
async def fake_execute_download(self, **kwargs):
return {"success": True}
monkeypatch.setattr(DownloadManager, "_execute_download", fake_execute_download)
# AFTER (Good - tests actual logic with injected dependencies)
async def test_download_executes_with_real_logic(
tmp_path, mock_downloader, mock_websocket
):
manager = DownloadManager(
downloader=mock_downloader,
ws_manager=mock_websocket
)
result = await manager._execute_download(urls=["http://test.com/file.safetensors"])
assert result.success is True
assert mock_downloader.download_calls == 1
```
### 1.2 Add Missing Error Path Tests
**Problem:** Error handling code is not tested, leading to production failures.
**Required Tests:**
| Error Type | Module | Priority |
|------------|--------|----------|
| Network timeout | `downloader.py` | P0 |
| Disk full | `download_manager.py` | P0 |
| Permission denied | `example_images_download_manager.py` | P0 |
| Session refresh failure | `downloader.py` | P1 |
| Partial file cleanup | `download_manager.py` | P1 |
**Implementation:**
```python
@pytest.mark.asyncio
async def test_download_handles_network_timeout():
"""Verify download retries on timeout and eventually fails gracefully."""
# Arrange
downloader = Downloader()
mock_session = AsyncMock()
mock_session.get.side_effect = asyncio.TimeoutError()
# Act
success, message = await downloader.download_file(
url="http://test.com/file.safetensors",
target_path=tmp_path / "test.safetensors",
session=mock_session
)
# Assert
assert success is False
assert "timeout" in message.lower()
assert mock_session.get.call_count == MAX_RETRIES
```
### 1.3 Standardize Async Test Patterns
**Problem:** Inconsistent async test patterns across codebase.
**Current State:**
- Some use `@pytest.mark.asyncio`
- Some rely on custom `pytest_pyfunc_call` in conftest.py
- Some use bare async functions
**Solution:**
1. Add `pytest-asyncio` to requirements-dev.txt
2. Update `pytest.ini`:
```ini
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
```
3. Remove custom `pytest_pyfunc_call` handler from conftest.py
4. Bulk update all async tests to use `@pytest.mark.asyncio`
**Migration Script:**
```bash
# Find all async test functions missing decorator
rg "^async def test_" tests/ --type py -A1 | grep -B1 "@pytest.mark" | grep "async def"
# Add decorator (manual review required)
```
---
## Phase 2: Integration & Coverage (P1) - Week 3-4
### 2.1 Add Critical Module Tests
**Priority 1: `py/services/model_lifecycle_service.py`**
```python
# tests/services/test_model_lifecycle_service.py
class TestModelLifecycleService:
async def test_create_model_registers_in_cache(self):
"""Verify new model is registered in both cache and database."""
async def test_delete_model_cleans_up_files_and_cache(self):
"""Verify deletion removes files and updates all indexes."""
async def test_update_model_metadata_propagates_changes(self):
"""Verify metadata updates reach all subscribers."""
```
**Priority 2: `py/services/persistent_recipe_cache.py`**
```python
# tests/services/test_persistent_recipe_cache.py
class TestPersistentRecipeCache:
def test_initialization_creates_schema(self):
"""Verify SQLite schema is created on first use."""
async def test_save_recipe_persists_to_sqlite(self):
"""Verify recipe data is saved correctly."""
async def test_concurrent_access_does_not_corrupt_database(self):
"""Verify thread safety under concurrent writes."""
```
**Priority 3: Route Handler Tests**
- `py/routes/handlers/preview_handlers.py`
- `py/routes/handlers/misc_handlers.py`
- `py/routes/handlers/model_handlers.py`
### 2.2 Add End-to-End Integration Tests
**Download Flow Integration Test:**
```python
# tests/integration/test_download_flow.py
@pytest.mark.integration
@pytest.mark.asyncio
async def test_complete_download_flow(tmp_path, test_server):
"""
Integration test covering:
1. Route receives download request
2. DownloadCoordinator schedules it
3. DownloadManager executes actual download
4. Downloader makes HTTP request (to test server)
5. Progress is broadcast via WebSocket
6. File is saved and cache updated
"""
# Setup test server with known file
test_file = tmp_path / "test_model.safetensors"
test_file.write_bytes(b"fake model data")
# Start download
async with aiohttp.ClientSession() as session:
response = await session.post(
"http://localhost:8188/api/lm/download",
json={"urls": [f"http://localhost:{test_server.port}/test_model.safetensors"]}
)
assert response.status == 200
# Verify file downloaded
downloaded = tmp_path / "downloads" / "test_model.safetensors"
assert downloaded.exists()
assert downloaded.read_bytes() == b"fake model data"
# Verify WebSocket progress updates
assert len(ws_manager.broadcasts) > 0
assert any(b["status"] == "completed" for b in ws_manager.broadcasts)
```
**Recipe Flow Integration Test:**
```python
# tests/integration/test_recipe_flow.py
@pytest.mark.integration
@pytest.mark.asyncio
async def test_recipe_analysis_and_save_flow(tmp_path):
"""
Integration test covering:
1. Import recipe from image
2. Parse metadata and extract models
3. Save to cache and database
4. Retrieve and display
"""
```
### 2.3 Strengthen Assertions
**Replace loose assertions:**
```python
# BEFORE
assert "mismatch" in message.lower()
# AFTER
assert message == "File size mismatch. Expected: 1000 bytes, Got: 500 bytes"
assert not target_path.exists()
assert not Path(str(target_path) + ".part").exists()
assert len(downloader.retry_history) == 3
```
**Add state verification:**
```python
# BEFORE
assert result is True
# AFTER
assert result is True
assert model["status"] == "downloaded"
assert model["file_path"].exists()
assert cache.get_by_hash(model["sha256"]) is not None
assert len(ws_manager.payloads) >= 2 # Started + completed
```
---
## Phase 4 Completion Summary (2026-02-11)
### Completed Items
1. **Property-Based Tests (Hypothesis)** ✅
- Created `tests/utils/test_utils_hypothesis.py` with 19 property-based tests
- Tests cover:
- `sanitize_folder_name` idempotency and invalid character handling (4 tests)
- `_sanitize_library_name` idempotency and safe character filtering (2 tests)
- `normalize_path` idempotency and forward slash usage (2 tests)
- `fuzzy_match` edge cases and threshold behavior (3 tests)
- `determine_base_model` return type guarantees (2 tests)
- `get_preview_extension` return type validation (2 tests)
- `calculate_recipe_fingerprint` determinism and ordering (4 tests)
- Fixed Hypothesis plugin compatibility issue by creating a `MockModule` class in `conftest.py` that is hashable (unlike `types.SimpleNamespace`)
2. **Snapshot Tests (Syrupy)** ✅
- Created `tests/routes/test_api_snapshots.py` with 7 snapshot tests
- Tests cover:
- SettingsHandler response formats (2 tests)
- NodeRegistryHandler response formats (2 tests)
- Utility function output verification (2 tests)
- ModelLibraryHandler empty response format (1 test)
- All snapshots generated and tests passing (7/7)
3. **Performance Benchmarks** ✅
- Created `tests/performance/test_cache_performance.py` with 11 benchmark tests
- Tests cover:
- Hash index lookup performance (100, 1K, 10K models) - 3 tests
- Hash index add entry performance (100, 10K existing) - 2 tests
- Fuzzy matching performance (short text, long text, many words) - 3 tests
- Recipe fingerprint calculation (5, 50, 200 LoRAs) - 3 tests
- All benchmarks passing with performance metrics (11/11)
4. **Package Dependencies** ✅
- Added `hypothesis>=6.0` to `requirements-dev.txt`
- Added `syrupy>=5.0` to `requirements-dev.txt`
- Added `pytest-benchmark>=5.0` to `requirements-dev.txt`
### Test Results
- **Property-Based Tests:** 19/19 passing
- **Snapshot Tests:** 7/7 passing
- **Performance Benchmarks:** 11/11 passing
- **Total New Tests Added:** 37 tests
- **Full Test Suite:** 947/947 passing
---
## Phase 3 Completion Summary (2026-02-11)
### Completed Items
1. **Centralized Test Fixtures** ✅
- Added `mock_downloader` fixture to `tests/conftest.py`
- Configurable mock with `should_fail` and `return_value` attributes
- Records all download calls for verification
- Added `mock_websocket_manager` fixture to `tests/conftest.py`
- Recording WebSocket manager that captures all broadcast payloads
- Includes helper method `get_payloads_by_type()` for filtering
- Added `reset_singletons` autouse fixture to `tests/conftest.py`
- Resets DownloadManager, ServiceRegistry, ModelScanner, and SettingsManager
- Ensures test isolation and prevents singleton pollution
2. **Split Large Test Files** ✅
- Split `tests/services/test_download_manager.py` (1422 lines) into:
- `test_download_manager_basic.py` - Core functionality (12 tests)
- `test_download_manager_error.py` - Error handling and execution (15 tests)
- `test_download_manager_concurrent.py` - Advanced scenarios (6 tests)
- Split `tests/utils/test_cache_paths.py` (530 lines) into:
- `test_cache_paths_resolution.py` - Path resolution and CacheType tests (11 tests)
- `test_cache_paths_validation.py` - Legacy path validation and cleanup (9 tests)
- `test_cache_paths_migration.py` - Migration scenarios and auto-cleanup (9 tests)
3. **Complex Test Refactoring** ✅
- Reviewed `test_example_images_download_manager_unit.py`
- Existing async event-based patterns are appropriate for testing concurrent behavior
- No refactoring needed - tests follow consistent patterns and are maintainable
### Test Results
- **Download Manager Tests:** 33/33 passing across 3 files
- **Cache Paths Tests:** 29/29 passing across 3 files
- **Total Tests Maintained:** All existing tests preserved and organized
---
## Phase 3: Architecture & Maintainability (P2) - Week 5-6
### 3.1 Centralize Test Fixtures
**Create `tests/conftest.py` improvements:**
```python
# tests/conftest.py additions
@pytest.fixture
def mock_downloader():
"""Provide a configurable mock downloader."""
class MockDownloader:
def __init__(self):
self.download_calls = []
self.should_fail = False
async def download_file(self, url, target_path, **kwargs):
self.download_calls.append({"url": url, "target_path": target_path})
if self.should_fail:
return False, "Download failed"
return True, str(target_path)
return MockDownloader()
@pytest.fixture
def mock_websocket_manager():
"""Provide a recording WebSocket manager."""
class RecordingWebSocketManager:
def __init__(self):
self.payloads = []
async def broadcast(self, payload):
self.payloads.append(payload)
return RecordingWebSocketManager()
@pytest.fixture
def mock_scanner():
"""Provide a mock model scanner with configurable cache."""
# ... existing MockScanner but improved ...
@pytest.fixture(autouse=True)
def reset_singletons():
"""Reset all singletons before each test."""
# Centralized singleton reset
DownloadManager._instance = None
ServiceRegistry.clear_services()
ModelScanner._instances.clear()
yield
# Cleanup
DownloadManager._instance = None
ServiceRegistry.clear_services()
ModelScanner._instances.clear()
```
### 3.2 Split Large Test Files
**Target Files:**
- `tests/services/test_download_manager.py` (1000+ lines) → Split into:
- `test_download_manager_basic.py` - Core functionality
- `test_download_manager_error.py` - Error handling
- `test_download_manager_concurrent.py` - Concurrent operations
- `tests/utils/test_cache_paths.py` (529 lines) → Split into:
- `test_cache_paths_resolution.py`
- `test_cache_paths_validation.py`
- `test_cache_paths_migration.py`
### 3.3 Refactor Complex Tests
**Example: Simplify test setup in `test_example_images_download_manager_unit.py`**
**Current (Complex):**
```python
async def test_start_download_bootstraps_progress_and_task(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
# 40+ lines of setup
started = asyncio.Event()
release = asyncio.Event()
async def fake_download(self, ...):
started.set()
await release.wait()
# ... more logic ...
```
**Improved (Using fixtures):**
```python
async def test_start_download_bootstraps_progress_and_task(
download_manager_with_fake_backend, release_event
):
# Setup in fixtures, test is clean
manager = download_manager_with_fake_backend
result = await manager.start_download({"model_types": ["lora"]})
assert result["success"] is True
assert manager._is_downloading is True
```
---
## Phase 4: Advanced Testing (P3) - Week 7-8
### 4.1 Add Property-Based Tests (Hypothesis)
**Install:** `pip install hypothesis`
**Example:**
```python
# tests/utils/test_hash_utils_hypothesis.py
from hypothesis import given, strategies as st
@given(st.text(min_size=1, max_size=100))
def test_hash_normalization_idempotent(name):
"""Hash normalization should be idempotent."""
normalized = normalize_hash(name)
assert normalize_hash(normalized) == normalized
@given(st.lists(st.dictionaries(st.text(), st.text()), min_size=0, max_size=1000))
def test_model_cache_handles_any_model_list(models):
"""Cache should handle any list of models without crashing."""
cache = ModelCache()
cache.raw_data = models
# Should not raise
list(cache.iter_models())
```
### 4.2 Add Snapshot Tests (Syrupy)
**Install:** `pip install syrupy`
**Example:**
```python
# tests/routes/test_api_snapshots.py
import pytest
@pytest.mark.asyncio
async def test_lora_list_response_format(snapshot, client):
"""Verify API response format matches snapshot."""
response = await client.get("/api/lm/loras")
data = await response.json()
assert data == snapshot # Syrupy handles this
```
### 4.3 Add Performance Benchmarks
**Install:** `pip install pytest-benchmark`
**Example:**
```python
# tests/performance/test_cache_performance.py
import pytest
def test_cache_lookup_performance(benchmark):
"""Benchmark cache lookup with 10,000 models."""
cache = create_cache_with_n_models(10000)
result = benchmark(lambda: cache.get_by_hash("abc123"))
# Benchmark automatically collects timing stats
```
---
## Implementation Checklist
### Week 1-2: Critical Fixes
- [x] Fix over-mocking in `test_download_manager.py` (Skipped - requires major refactoring, see Phase 2)
- [x] Add network timeout tests (Added `test_downloader_error_paths.py` with 19 error path tests)
- [x] Add disk full error tests (Covered in error path tests)
- [x] Add permission denied tests (Covered in error path tests)
- [x] Install and configure pytest-asyncio (Added to requirements-dev.txt and pytest.ini)
- [x] Remove custom pytest_pyfunc_call handler (Removed from conftest.py)
- [x] Add `@pytest.mark.asyncio` to all async tests (Added to 21 async test functions in test_download_manager.py)
### Week 3-4: Integration & Coverage
- [x] Create `test_model_lifecycle_service.py` tests (12 new tests added)
- [x] Create `test_persistent_recipe_cache.py` tests (5 new concurrent access tests added)
- [x] Create `tests/integration/` directory (created with conftest.py)
- [x] Add download flow integration test (7 tests added)
- [x] Add recipe flow integration test (9 tests added)
- [x] Add route handler tests for preview_handlers.py (already exists in test_preview_routes.py)
- [x] Strengthen assertions across integration tests (comprehensive assertions added)
### Week 5-6: Architecture
- [x] Add centralized fixtures to conftest.py
- [x] Split `test_download_manager.py` into 3 files
- [x] Split `test_cache_paths.py` into 3 files
- [x] Refactor complex test setups (reviewed - no changes needed)
- [x] Remove duplicate singleton reset fixtures (consolidated in conftest.py)
### Week 7-8: Advanced Testing
- [x] Install hypothesis (Added to requirements-dev.txt)
- [x] Add 10 property-based tests (Created 19 tests in test_utils_hypothesis.py)
- [x] Install syrupy (Added to requirements-dev.txt)
- [x] Add 5 snapshot tests (Created 7 tests in test_api_snapshots.py)
- [x] Install pytest-benchmark (Added to requirements-dev.txt)
- [x] Add 3 performance benchmarks (Created 11 tests in test_cache_performance.py)
---
## Success Metrics
### Quantitative
- **Code Coverage:** Increase from ~70% to >90%
- **Test Count:** Increase from 400+ to 600+
- **Assertion Strength:** Replace 50+ weak assertions
- **Integration Test Ratio:** Increase from 5% to 20%
### Qualitative
- **Bug Escape Rate:** Reduce by 80%
- **Test Maintenance Time:** Reduce by 50%
- **Time to Write New Tests:** Reduce by 30%
- **CI Pipeline Speed:** Maintain <5 minutes
---
## Risk Mitigation
| Risk | Mitigation |
|------|------------|
| Breaking existing tests | Run full test suite after each change |
| Increased CI time | Optimize tests, parallelize execution |
| Developer resistance | Provide training, pair programming |
| Maintenance burden | Document patterns, provide templates |
| Coverage gaps | Use coverage.py in CI, fail on <90% |
---
## Related Documents
- `docs/testing/frontend-testing-roadmap.md` - Frontend testing plan
- `docs/AGENTS.md` - Development guidelines
- `pytest.ini` - Test configuration
- `tests/conftest.py` - Shared fixtures
---
## Approval
| Role | Name | Date | Signature |
|------|------|------|-----------|
| Tech Lead | | | |
| QA Lead | | | |
| Product Owner | | | |
---
**Next Review Date:** 2026-02-25
**Document Owner:** Backend Team

View File

@@ -1,196 +0,0 @@
# Settings Modal Optimization Progress Tracker
## Project Overview
**Goal**: Optimize Settings Modal UI/UX with left navigation sidebar
**Started**: 2026-02-23
**Current Phase**: P2 - Search Bar (Completed)
---
## Phase 0: Left Navigation Sidebar (P0)
### Status: Completed ✓
### Completion Notes
- All CSS changes implemented
- HTML structure restructured successfully
- JavaScript navigation functionality added
- Translation keys added and synchronized
- Ready for testing and review
### Tasks
#### 1. CSS Changes
- [x] Add two-column layout styles
- [x] `.settings-modal` flex layout
- [x] `.settings-nav` sidebar styles
- [x] `.settings-content` content area styles
- [x] `.settings-nav-item` navigation item styles
- [x] `.settings-nav-item.active` active state styles
- [x] Adjust modal width to 950px
- [x] Add smooth scroll behavior
- [x] Add responsive styles for mobile
- [x] Ensure dark theme compatibility
#### 2. HTML Changes
- [x] Restructure modal HTML
- [x] Wrap content in two-column container
- [x] Add navigation sidebar structure
- [x] Add navigation items for each section
- [x] Add ID anchors to each section
- [x] Update section grouping if needed
#### 3. JavaScript Changes
- [x] Add navigation click handlers
- [x] Implement smooth scroll to section
- [x] Add scroll spy for active nav highlighting
- [x] Handle nav item click events
- [x] Update SettingsManager initialization
#### 4. Translation Keys
- [x] Add translation keys for navigation groups
- [x] `settings.nav.general`
- [x] `settings.nav.interface`
- [x] `settings.nav.download`
- [x] `settings.nav.advanced`
#### 4. Testing
- [x] Verify navigation clicks work
- [x] Verify active highlighting works
- [x] Verify smooth scrolling works
- [ ] Test on mobile viewport (deferred to final QA)
- [ ] Test dark/light theme (deferred to final QA)
- [x] Verify all existing settings work
- [x] Verify save/load functionality
### Blockers
None currently
### Notes
- Started implementation on 2026-02-23
- Following existing design system and CSS variables
---
## Phase 1: Section Collapse/Expand (P1)
### Status: Completed ✓
### Completion Notes
- All sections now have collapse/expand functionality
- Chevron icon rotates smoothly on toggle
- State persistence via localStorage working correctly
- CSS animations for smooth height transitions
- Settings order reorganized to match sidebar navigation
### Tasks
- [x] Add collapse/expand toggle to section headers
- [x] Add chevron icon with rotation animation
- [x] Implement localStorage for state persistence
- [x] Add CSS animations for smooth transitions
- [x] Reorder settings sections to match sidebar navigation
---
## Phase 2: Search Bar (P1)
### Status: Completed ✓
### Completion Notes
- Search input added to settings modal header with icon and clear button
- Real-time filtering with debounced input (150ms delay)
- Highlight matching terms with accent color background
- Handle empty search results with user-friendly message
- Keyboard shortcuts: Escape to clear search
- Sections with matches are automatically expanded
- All translation keys added and synchronized across languages
### Tasks
- [x] Add search input to header area
- [x] Implement real-time filtering
- [x] Add highlight for matched terms
- [x] Handle empty search results
---
## Phase 3: Visual Hierarchy (P2)
### Status: Planned
### Tasks
- [ ] Add accent border to section headers
- [ ] Bold setting labels
- [ ] Increase section spacing
---
## Phase 4: Quick Actions (P3)
### Status: Planned
### Tasks
- [ ] Add reset to defaults button
- [ ] Add export config button
- [ ] Add import config button
- [ ] Implement corresponding functionality
---
## Change Log
### 2026-02-23 (P2)
- Completed Phase 2: Search Bar
- Added search input to settings modal header with search icon and clear button
- Implemented real-time filtering with 150ms debounce for performance
- Added visual highlighting for matched search terms using accent color
- Implemented empty search results state with user-friendly message
- Added keyboard shortcuts (Escape to clear search)
- Sections with matching content are automatically expanded during search
- Updated SettingsManager.js with search initialization and filtering logic
- Added comprehensive CSS styles for search input, highlights, and responsive design
- Added translation keys for search feature (placeholder, clear, no results)
- Synchronized translations across all language files
### 2026-02-23 (P1)
- Completed Phase 1: Section Collapse/Expand
- Added collapse/expand functionality to all settings sections
- Implemented chevron icon with smooth rotation animation
- Added localStorage persistence for collapse state
- Reorganized settings sections to match sidebar navigation order
- Updated SettingsManager.js with section collapse initialization
- Added CSS styles for smooth transitions and animations
### 2026-02-23 (P0)
- Created project documentation
- Started Phase 0 implementation
- Analyzed existing code structure
- Implemented two-column layout with left navigation sidebar
- Added CSS styles for navigation and responsive design
- Restructured HTML to support new layout
- Added JavaScript navigation functionality with scroll spy
- Added translation keys for navigation groups
- Synchronized translations across all language files
- Tested in browser - navigation working correctly
---
## Testing Checklist
### Functional Testing
- [ ] All settings save correctly
- [ ] All settings load correctly
- [ ] Navigation scrolls to correct section
- [ ] Active nav updates on scroll
- [ ] Mobile responsive layout
### Visual Testing
- [ ] Design matches existing UI
- [ ] Dark theme looks correct
- [ ] Light theme looks correct
- [ ] Animations are smooth
- [ ] No layout shifts or jumps
### Cross-browser Testing
- [ ] Chrome/Chromium
- [ ] Firefox
- [ ] Safari (if available)

View File

@@ -1,331 +0,0 @@
# Settings Modal UI/UX Optimization
## Overview
当前Settings Modal采用单列表长页面设计随着设置项不断增加已难以高效浏览和定位。本方案采用 **macOS Settings 模式**(左侧导航 + 右侧单Section独占显示在保持原有设计语言的前提下重构信息架构大幅提升用户体验。
## Goals
1. **提升浏览效率**:用户能够快速定位和修改设置
2. **保持设计一致性**:延续现有的颜色、间距、动画系统
3. **简化交互模型**移除冗余元素SETTINGS label、折叠功能
4. **清晰的视觉层次**Section级导航右侧独占显示
5. **向后兼容**:不影响现有功能逻辑
## Design Principles
- **macOS Settings模式**点击左侧导航右侧仅显示该Section内容
- **贴近原有设计语言**使用现有CSS变量和样式模式
- **最小化风格改动**在提升UX的同时保持视觉风格稳定
- **简化优于复杂**:移除不必要的折叠/展开交互
---
## New Design Architecture
### Layout Structure
```
┌─────────────────────────────────────────────────────────────┐
│ Settings [×] │
├──────────────┬──────────────────────────────────────────────┤
│ NAVIGATION │ CONTENT │
│ │ │
│ General → │ ┌─────────────────────────────────────────┐ │
│ Interface │ │ General │ │
│ Download │ │ ═══════════════════════════════════════ │ │
│ Advanced │ │ │ │
│ │ │ ┌─────────────────────────────────────┐ │ │
│ │ │ │ Civitai API Key │ │ │
│ │ │ │ [ ] [?] │ │ │
│ │ │ └─────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ │ ┌─────────────────────────────────────┐ │ │
│ │ │ │ Settings Location │ │ │
│ │ │ │ [/path/to/settings] [Browse] │ │ │
│ │ │ └─────────────────────────────────────┘ │ │
│ │ └─────────────────────────────────────────┘ │
│ │ │
│ │ [Cancel] [Save Changes] │
└──────────────┴──────────────────────────────────────────────┘
```
### Key Design Decisions
#### 1. 移除冗余元素
- ❌ 删除 sidebar 中的 "SETTINGS" label
-**取消折叠/展开功能**(增加交互成本,无实际收益)
- ❌ 不再在左侧导航显示具体设置项(减少认知负荷)
#### 2. 导航简化
- 左侧仅显示 **4个Section**General / Interface / Download / Advanced
- 当前选中项用 accent 色 background highlight
- 无需滚动监听,点击即切换
#### 3. 右侧单Section独占
- 点击左侧导航右侧仅显示该Section的所有设置项
- Section标题作为页面标题大号字体 + accent色下划线
- 所有设置项平铺展示,无需折叠
#### 4. 视觉层次
```
Section Header (20px, bold, accent underline)
├── Setting Group (card container, subtle border)
│ ├── Setting Label (14px, semibold)
│ ├── Setting Description (12px, muted color)
│ └── Setting Control (input/select/toggle)
```
---
## Optimization Phases
### Phase 0: macOS Settings模式重构 (P0)
**Status**: Ready for Development
**Priority**: High
#### Goals
- 重构为两栏布局(左侧导航 + 右侧内容)
- 实现Section级导航切换
- 优化视觉层次和间距
- 移除冗余元素
#### Implementation Details
##### Layout Specifications
| Element | Specification |
|---------|--------------|
| Modal Width | 800px (比原700px稍宽) |
| Modal Height | 600px (固定高度) |
| Left Sidebar | 200px 固定宽度 |
| Right Content | flex: 1自动填充 |
| Content Padding | --space-3 (24px) |
##### Navigation Structure
```
General (通用)
├── Language
├── Civitai API Key
└── Settings Location
Interface (界面)
├── Layout Settings
├── Video Settings
└── Content Filtering
Download (下载)
├── Folder Settings
├── Download Path Templates
├── Example Images
└── Update Flags
Advanced (高级)
├── Priority Tags
├── Auto-organize exclusions
├── Metadata refresh skip paths
├── Metadata Archive Database
├── Proxy Settings
└── Misc
```
##### CSS Style Guide
**Section Header**
```css
.settings-section-header {
font-size: 20px;
font-weight: 600;
padding-bottom: var(--space-2);
border-bottom: 2px solid var(--lora-accent);
margin-bottom: var(--space-3);
}
```
**Setting Group (Card)**
```css
.settings-group {
background: var(--card-bg);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
```
**Setting Item**
```css
.setting-item {
margin-bottom: var(--space-3);
}
.setting-item:last-child {
margin-bottom: 0;
}
.setting-label {
font-size: 14px;
font-weight: 500;
margin-bottom: var(--space-1);
}
.setting-description {
font-size: 12px;
color: var(--text-muted);
margin-bottom: var(--space-2);
}
```
**Sidebar Navigation**
```css
.settings-nav-item {
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: background 0.2s ease;
}
.settings-nav-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.settings-nav-item.active {
background: var(--lora-accent);
color: white;
}
```
#### Files to Modify
1. **static/css/components/modal/settings-modal.css**
- [ ] 新增两栏布局样式
- [ ] 新增侧边栏导航样式
- [ ] 新增Section标题样式
- [ ] 调整设置项卡片样式
- [ ] 移除折叠相关的CSS
2. **templates/components/modals/settings_modal.html**
- [ ] 重构为两栏HTML结构
- [ ] 添加4个导航项
- [ ] 将Section改为独立内容区域
- [ ] 移除折叠按钮HTML
3. **static/js/managers/SettingsManager.js**
- [ ] 添加导航点击切换逻辑
- [ ] 添加Section显示/隐藏控制
- [ ] 移除折叠/展开相关代码
- [ ] 默认显示第一个Section
---
### Phase 1: 搜索功能 (P1)
**Status**: Planned
**Priority**: Medium
#### Goals
- 快速定位特定设置项
- 支持关键词搜索设置标签和描述
#### Implementation
- 搜索框保持在顶部右侧
- 实时过滤显示匹配的Section和设置项
- 高亮匹配的关键词
- 无结果时显示友好提示
---
### Phase 2: 操作按钮优化 (P2)
**Status**: Planned
**Priority**: Low
#### Goals
- 增强功能完整性
- 提供批量操作能力
#### Implementation
- 底部固定操作栏position: sticky
- [Cancel] 和 [Save Changes] 按钮
- 可选:重置为默认、导出配置、导入配置
---
## Migration Notes
### Removed Features
| Feature | Reason |
|---------|--------|
| Section折叠/展开 | 单Section独占显示后不再需要 |
| 滚动监听高亮 | 改为点击切换,无需监听滚动 |
| 长页面平滑滚动 | 内容不再超长,无需滚动 |
| "SETTINGS" label | 冗余信息移除以简化UI |
### Preserved Features
- 所有设置项功能和逻辑
- 表单验证
- 设置项描述和提示
- 原有的CSS变量系统
---
## Success Criteria
### Phase 0
- [ ] Modal显示为两栏布局
- [ ] 左侧显示4个Section导航
- [ ] 点击导航切换右侧显示的Section
- [ ] 当前选中导航项高亮显示
- [ ] Section标题有accent色下划线
- [ ] 设置项以卡片形式分组展示
- [ ] 移除所有折叠/展开功能
- [ ] 移动端响应式正常(单栏堆叠)
- [ ] 所有现有设置功能正常工作
- [ ] 设计风格与原有UI一致
### Phase 1
- [ ] 搜索框可输入关键词
- [ ] 实时过滤显示匹配项
- [ ] 高亮匹配的关键词
### Phase 2
- [ ] 底部有固定操作按钮栏
- [ ] Cancel和Save Changes按钮工作正常
---
## Timeline
| Phase | Estimated Time | Status |
|-------|---------------|--------|
| P0 | 3-4 hours | Ready for Development |
| P1 | 2-3 hours | Planned |
| P2 | 1-2 hours | Planned |
---
## Reference
### Design Inspiration
- **macOS System Settings**: 左侧导航 + 右侧单Section独占
- **VS Code Settings**: 清晰的视觉层次和搜索体验
- **Linear**: 简洁的两栏布局设计
### CSS Variables Reference
```css
/* Colors */
--lora-accent: #007AFF;
--lora-border: rgba(255, 255, 255, 0.1);
--card-bg: rgba(255, 255, 255, 0.05);
--text-color: #ffffff;
--text-muted: rgba(255, 255, 255, 0.6);
/* Spacing */
--space-1: 8px;
--space-2: 12px;
--space-3: 16px;
--space-4: 24px;
/* Border Radius */
--border-radius-xs: 4px;
--border-radius-sm: 8px;
```
---
**Last Updated**: 2025-02-24
**Author**: AI Assistant
**Status**: Ready for Implementation

View File

@@ -1,191 +0,0 @@
# Settings Modal Optimization Progress
**Project**: Settings Modal UI/UX Optimization
**Status**: Phase 0 - Ready for Development
**Last Updated**: 2025-02-24
---
## Phase 0: macOS Settings模式重构
### Overview
重构Settings Modal为macOS Settings模式左侧Section导航 + 右侧单Section独占显示。移除冗余元素优化视觉层次。
### Tasks
#### 1. CSS Updates ✅
**File**: `static/css/components/modal/settings-modal.css`
- [x] **Layout Styles**
- [x] Modal固定尺寸 800x600px
- [x] 左侧 sidebar 固定宽度 200px
- [x] 右侧 content flex: 1 自动填充
- [x] **Navigation Styles**
- [x] `.settings-nav` 容器样式
- [x] `.settings-nav-item` 基础样式更大字体更醒目的active状态
- [x] `.settings-nav-item.active` 高亮样式accent背景
- [x] `.settings-nav-item:hover` 悬停效果
- [x] 隐藏 "SETTINGS" label
- [x] 隐藏 group titles
- [x] **Content Area Styles**
- [x] `.settings-section` 默认隐藏(仅当前显示)
- [x] `.settings-section.active` 显示状态
- [x] `.settings-section-header` 标题样式20px + accent下划线
- [x] 添加 fadeIn 动画效果
- [x] **Cleanup**
- [x] 移除折叠相关样式
- [x] 移除 `.settings-section-toggle` 按钮样式
- [x] 移除展开/折叠动画样式
**Status**: ✅ Completed
---
#### 2. HTML Structure Update ✅
**File**: `templates/components/modals/settings_modal.html`
- [x] **Navigation Items**
- [x] General (通用)
- [x] Interface (界面)
- [x] Download (下载)
- [x] Advanced (高级)
- [x] 移除 "SETTINGS" label
- [x] 移除 group titles
- [x] **Content Sections**
- [x] 重组为4个Section (general/interface/download/advanced)
- [x] 每个section添加 `data-section` 属性
- [x] 添加Section标题带accent下划线
- [x] 移除所有折叠按钮chevron图标
- [x] 平铺显示所有设置项
**Status**: ✅ Completed
---
#### 3. JavaScript Logic Update ✅
**File**: `static/js/managers/SettingsManager.js`
- [x] **Navigation Logic**
- [x] `initializeNavigation()` 改为Section切换模式
- [x] 点击导航项显示对应Section
- [x] 更新导航高亮状态
- [x] 默认显示第一个Section
- [x] **Remove Legacy Code**
- [x] 移除 `initializeSectionCollapse()` 方法
- [x] 移除滚动监听相关代码
- [x] 移除 `localStorage` 折叠状态存储
- [x] **Search Function**
- [x] 更新搜索功能以适配新显示模式
- [x] 搜索时自动切换到匹配的Section
- [x] 高亮匹配的关键词
**Status**: ✅ Completed
---
### Testing Checklist
#### Visual Testing
- [ ] 两栏布局正确显示
- [ ] 左侧导航4个Section正确显示
- [ ] 点击导航切换右侧内容
- [ ] 当前导航项高亮显示accent背景
- [ ] Section标题有accent色下划线
- [ ] 设置项以卡片形式分组
- [ ] 无"SETTINGS" label
- [ ] 无折叠/展开按钮
#### Functional Testing
- [ ] 所有设置项可正常编辑
- [ ] 设置保存功能正常
- [ ] 设置加载功能正常
- [ ] 表单验证正常工作
- [ ] 帮助提示tooltip正常显示
#### Responsive Testing
- [ ] 桌面端(>768px两栏布局
- [ ] 移动端(<768px单栏堆叠
- [ ] 移动端导航可正常切换
#### Cross-Browser Testing
- [ ] Chrome/Edge
- [ ] Firefox
- [ ] Safari如适用
---
## Phase 1: 搜索功能
### Tasks
- [ ] 搜索框UI更新
- [ ] 搜索逻辑实现
- [ ] 实时过滤显示
- [ ] 关键词高亮
**Estimated Time**: 2-3 hours
**Status**: 📋 Planned
---
## Phase 2: 操作按钮优化
### Tasks
- [ ] 底部操作栏样式
- [ ] 固定定位sticky
- [ ] Cancel/Save按钮功能
- [ ] 可选Reset/Export/Import
**Estimated Time**: 1-2 hours
**Status**: 📋 Planned
---
## Progress Summary
| Phase | Progress | Status |
|-------|----------|--------|
| Phase 0 | 100% | Completed |
| Phase 1 | 0% | 📋 Planned |
| Phase 2 | 0% | 📋 Planned |
**Overall Progress**: 100% (Phase 0)
---
## Development Log
### 2025-02-24
- 创建优化提案文档macOS Settings模式
- 创建进度追踪文档
- Phase 0 开发完成
- CSS重构完成新增macOS Settings样式移除折叠相关样式
- HTML重构完成重组为4个Section移除所有折叠按钮
- JavaScript重构完成实现Section切换逻辑更新搜索功能
---
## Notes
### Design Decisions
- 采用macOS Settings模式而非长页面滚动模式
- 左侧仅显示4个Section不显示具体设置项
- 移除折叠/展开功能简化交互
- Section标题使用accent色下划线强调
### Technical Notes
- 优先使用现有CSS变量
- 保持向后兼容不破坏现有设置存储逻辑
- 移动端响应式小屏幕单栏堆叠
### Blockers
None
---
**Next Action**: Start Phase 0 - CSS Updates

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"actions": { "actions": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen",
"delete": "Löschen", "delete": "Löschen",
"move": "Verschieben", "move": "Verschieben",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
@@ -14,8 +11,7 @@
"backToTop": "Nach oben", "backToTop": "Nach oben",
"settings": "Einstellungen", "settings": "Einstellungen",
"help": "Hilfe", "help": "Hilfe",
"add": "Hinzufügen", "add": "Hinzufügen"
"close": "Schließen"
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "Update", "update": "Update",
"updateAvailable": "Update verfügbar", "updateAvailable": "Update verfügbar"
"skipRefresh": "Metadaten-Aktualisierung übersprungen"
}, },
"usage": { "usage": {
"timesUsed": "Verwendungsanzahl" "timesUsed": "Verwendungsanzahl"
@@ -184,6 +179,7 @@
"recipes": "Rezepte", "recipes": "Rezepte",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Statistiken" "statistics": "Statistiken"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "LoRAs suchen...", "loras": "LoRAs suchen...",
"recipes": "Rezepte suchen...", "recipes": "Rezepte suchen...",
"checkpoints": "Checkpoints suchen...", "checkpoints": "Checkpoints suchen...",
"embeddings": "Embeddings suchen..." "embeddings": "Embeddings suchen...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "Suchoptionen", "options": "Suchoptionen",
"searchIn": "Suchen in:", "searchIn": "Suchen in:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "Voreinstellungsname...", "presetNamePlaceholder": "Voreinstellungsname...",
"baseModel": "Basis-Modell", "baseModel": "Basis-Modell",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Modelltypen", "modelTypes": "Model Types",
"license": "Lizenz", "license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags", "noTags": "Keine Tags",
"clearAll": "Alle Filter löschen", "clearAll": "Alle Filter löschen"
"any": "Beliebig",
"all": "Alle",
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
"tagLogicAll": "Alle Tags abgleichen (UND)"
}, },
"theme": { "theme": {
"toggle": "Theme wechseln", "toggle": "Theme wechseln",
@@ -262,27 +255,17 @@
"contentFiltering": "Inhaltsfilterung", "contentFiltering": "Inhaltsfilterung",
"videoSettings": "Video-Einstellungen", "videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen", "layoutSettings": "Layout-Einstellungen",
"misc": "Verschiedenes", "folderSettings": "Ordner-Einstellungen",
"folderSettings": "Standard-Roots",
"extraFolderPaths": "Zusätzliche Ordnerpfade",
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"priorityTags": "Prioritäts-Tags", "priorityTags": "Prioritäts-Tags",
"updateFlags": "Update-Markierungen", "downloadPathTemplates": "Download-Pfad-Vorlagen",
"exampleImages": "Beispielbilder", "exampleImages": "Beispielbilder",
"autoOrganize": "Auto-Organisierung", "updateFlags": "Update-Markierungen",
"metadata": "Metadaten", "autoOrganize": "Auto-organize",
"misc": "Verschiedenes",
"metadataArchive": "Metadaten-Archiv-Datenbank",
"storageLocation": "Einstellungsort",
"proxySettings": "Proxy-Einstellungen" "proxySettings": "Proxy-Einstellungen"
}, },
"nav": {
"general": "Allgemein",
"interface": "Oberfläche",
"library": "Bibliothek"
},
"search": {
"placeholder": "Einstellungen durchsuchen...",
"clear": "Suche löschen",
"noResults": "Keine Einstellungen gefunden für \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Portabler Modus", "locationLabel": "Portabler Modus",
"locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern." "locationHelp": "Aktiviere, um settings.json im Repository zu belassen; deaktiviere, um es im Benutzerkonfigurationsordner zu speichern."
@@ -306,15 +289,6 @@
"saveFailed": "Fehler beim Speichern der Ausschlüsse: {message}" "saveFailed": "Fehler beim Speichern der Ausschlüsse: {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "Metadaten-Aktualisierung: Übersprungene Pfade",
"placeholder": "Beispiel: temp, archived/old, test_models",
"help": "Modelle in diesen Verzeichnispfaden bei der Massenaktualisierung der Metadaten (\"Alle Metadaten abrufen\") überspringen. Geben Sie Ordnerpfade relativ zum Modell-Stammverzeichnis ein, getrennt durch Kommas.",
"validation": {
"noPaths": "Geben Sie mindestens einen durch Kommas getrennten Pfad ein.",
"saveFailed": "Übersprungene Pfade konnten nicht gespeichert werden: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Anzeige-Dichte", "displayDensity": "Anzeige-Dichte",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu.", "activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu.",
"loadingLibraries": "Bibliotheken werden geladen...", "loadingLibraries": "Bibliotheken werden geladen...",
"noLibraries": "Keine Bibliotheken konfiguriert", "noLibraries": "Keine Bibliotheken konfiguriert",
"defaultLoraRoot": "LoRA-Stammordner", "defaultLoraRoot": "Standard-LoRA-Stammordner",
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultCheckpointRoot": "Checkpoint-Stammordner", "defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
"defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultUnetRoot": "Diffusion-Modell-Stammordner", "defaultUnetRoot": "Standard-Diffusion-Modell-Stammordner",
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultEmbeddingRoot": "Embedding-Stammordner", "defaultEmbeddingRoot": "Standard-Embedding-Stammordner",
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"noDefault": "Kein Standard" "noDefault": "Kein Standard"
}, },
"extraFolderPaths": {
"title": "Zusätzliche Ordnerpfade",
"help": "Fügen Sie zusätzliche Modellordner außerhalb der Standardpfade von ComfyUI hinzu. Diese Pfade werden separat gespeichert und zusammen mit den Standardordnern gescannt.",
"description": "Konfigurieren Sie zusätzliche Ordner zum Scannen von Modellen. Diese Pfade sind spezifisch für LoRA Manager und werden mit den Standardpfaden von ComfyUI zusammengeführt.",
"modelTypes": {
"lora": "LoRA-Pfade",
"checkpoint": "Checkpoint-Pfade",
"unet": "Diffusionsmodell-Pfade",
"embedding": "Embedding-Pfade"
},
"pathPlaceholder": "/pfad/zu/extra/modellen",
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert.",
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
}
},
"priorityTags": { "priorityTags": {
"title": "Prioritäts-Tags", "title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))", "description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "Jede verfügbare Aktualisierung markieren" "any": "Jede verfügbare Aktualisierung markieren"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates"
},
"misc": { "misc": {
"includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen", "includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen",
"includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen" "includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen"
@@ -572,12 +525,8 @@
"checkUpdates": "Auswahl auf Updates prüfen", "checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben", "moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren", "autoOrganize": "Automatisch organisieren",
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"deleteAll": "Alle Modelle löschen", "deleteAll": "Alle Modelle löschen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Automatische Organisation wird initialisiert...", "initializing": "Automatische Organisation wird initialisiert...",
"starting": "Automatische Organisation für {type} wird gestartet...", "starting": "Automatische Organisation für {type} wird gestartet...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "Wenigste" "lorasCountAsc": "Wenigste"
}, },
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren", "title": "Rezeptliste aktualisieren"
"quick": "Änderungen synchronisieren",
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
"full": "Cache neu aufbauen",
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
}, },
"filteredByLora": "Gefiltert nach LoRA", "filteredByLora": "Gefiltert nach LoRA",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "Rezept-Reparatur fehlgeschlagen: {message}", "failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID" "missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Embedding-Modelle" "title": "Embedding-Modelle"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "Stammverzeichnis", "modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen", "collapseAll": "Alle Ordner einklappen",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
},
"empty": {
"noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "Unbenannte Version", "unnamed": "Unbenannte Version",
"noDetails": "Keine zusätzlichen Details", "noDetails": "Keine zusätzlichen Details"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "bald endend",
"hours": "in {count}h",
"days": "in {count}d"
}, },
"badges": { "badges": {
"current": "Aktuelle Version", "current": "Aktuelle Version",
"inLibrary": "In der Bibliothek", "inLibrary": "In der Bibliothek",
"newer": "Neuere Version", "newer": "Neuere Version",
"earlyAccess": "Früher Zugriff",
"ignored": "Ignoriert" "ignored": "Ignoriert"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "Löschen", "delete": "Löschen",
"ignore": "Ignorieren", "ignore": "Ignorieren",
"unignore": "Ignorierung aufheben", "unignore": "Ignorierung aufheben",
"earlyAccessTooltip": "Erfordert Early-Access-Kauf",
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen", "resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren", "ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
"viewLocalVersions": "Alle lokalen Versionen anzeigen", "viewLocalVersions": "Alle lokalen Versionen anzeigen",
@@ -1237,6 +1116,10 @@
"title": "Statistiken werden initialisiert", "title": "Statistiken werden initialisiert",
"message": "Modelldaten für Statistiken werden verarbeitet. Dies kann einige Minuten dauern..." "message": "Modelldaten für Statistiken werden verarbeitet. Dies kann einige Minuten dauern..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "Tipps & Tricks", "title": "Tipps & Tricks",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "Rezept zum Workflow hinzugefügt", "recipeAdded": "Rezept zum Workflow hinzugefügt",
"recipeReplaced": "Rezept im Workflow ersetzt", "recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow", "recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar", "noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt" "noTargetNodeSelected": "Kein Zielknoten ausgewählt"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Rezept", "recipe": "Rezept",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Ersetzen", "replace": "Ersetzen",
"append": "Anhängen", "append": "Anhängen",
"selectTargetNode": "Zielknoten auswählen", "selectTargetNode": "Zielknoten auswählen",
@@ -1418,14 +1307,7 @@
"showWechatQR": "WeChat QR-Code anzeigen", "showWechatQR": "WeChat QR-Code anzeigen",
"hideWechatQR": "WeChat QR-Code ausblenden" "hideWechatQR": "WeChat QR-Code ausblenden"
}, },
"footer": "Vielen Dank, dass Sie LoRA Manager verwenden! ❤️", "footer": "Vielen Dank, dass Sie LoRA Manager verwenden! ❤️"
"supporters": {
"title": "Danke an alle Unterstützer",
"subtitle": "Danke an {count} Unterstützer, die dieses Projekt möglich gemacht haben",
"specialThanks": "Besonderer Dank",
"allSupporters": "Alle Unterstützer",
"totalCount": "{count} Unterstützer insgesamt"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "Fehler beim Laden der {modelType}s: {message}", "loadFailed": "Fehler beim Laden der {modelType}s: {message}",
"refreshComplete": "Aktualisierung abgeschlossen", "refreshComplete": "Aktualisierung abgeschlossen",
"refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}", "refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}",
"syncComplete": "Synchronisation abgeschlossen",
"syncFailed": "Fehler beim Synchronisieren der Rezepte: {message}",
"updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}", "updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}",
"updateError": "Fehler beim Aktualisieren des Rezepts: {message}", "updateError": "Fehler beim Aktualisieren des Rezepts: {message}",
"nameSaved": "Rezept \"{name}\" erfolgreich gespeichert", "nameSaved": "Rezept \"{name}\" erfolgreich gespeichert",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}", "recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
"importFailed": "Import fehlgeschlagen: {message}", "importFailed": "Import fehlgeschlagen: {message}",
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums", "folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums", "folderTreeError": "Fehler beim Laden des Ordnerbaums"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "Keine Modelle ausgewählt", "noModelsSelected": "Keine Modelle ausgewählt",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert", "bulkBaseModelUpdateSuccess": "Basis-Modell erfolgreich für {count} Modell(e) aktualisiert",
"bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen", "bulkBaseModelUpdatePartial": "{success} Modelle aktualisiert, {failed} fehlgeschlagen",
"bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen", "bulkBaseModelUpdateFailed": "Aktualisierung des Basis-Modells für ausgewählte Modelle fehlgeschlagen",
"skipMetadataRefreshUpdating": "Aktualisiere Metadaten-Aktualisierungs-Flag für {count} Modell(e)...",
"skipMetadataRefreshSet": "Metadaten-Aktualisierung für {count} Modell(e) übersprungen",
"skipMetadataRefreshCleared": "Metadaten-Aktualisierung für {count} Modell(e) fortgesetzt",
"skipMetadataRefreshPartial": "{success} Modell(e) aktualisiert, {failed} fehlgeschlagen",
"skipMetadataRefreshFailed": "Fehler beim Aktualisieren des Metadaten-Aktualisierungs-Flags für ausgewählte Modelle",
"bulkContentRatingUpdating": "Inhaltsbewertung wird für {count} Modell(e) aktualisiert...", "bulkContentRatingUpdating": "Inhaltsbewertung wird für {count} Modell(e) aktualisiert...",
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt", "bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen", "bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums", "folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums", "folderTreeError": "Fehler beim Laden des Ordnerbaums",
"imagesImported": "Beispielbilder erfolgreich importiert", "imagesImported": "Beispielbilder erfolgreich importiert",
"imagesPartial": "{success} Bild(er) importiert, {failed} fehlgeschlagen",
"importFailed": "Fehler beim Importieren der Beispielbilder: {message}" "importFailed": "Fehler beim Importieren der Beispielbilder: {message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Cache-Korruption erkannt"
},
"degraded": {
"title": "Cache-Probleme erkannt"
},
"content": "{invalid} von {total} Cache-Einträgen sind ungültig ({rate}). Dies kann zu fehlenden Modellen oder Fehlern führen. Ein Neuaufbau des Caches wird empfohlen.",
"rebuildCache": "Cache neu aufbauen",
"dismiss": "Verwerfen",
"rebuilding": "Cache wird neu aufgebaut...",
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
"retry": "Wiederholen"
} }
} }
} }

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "Cancel",
"confirm": "Confirm",
"actions": { "actions": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete", "delete": "Delete",
"move": "Move", "move": "Move",
"refresh": "Refresh", "refresh": "Refresh",
@@ -14,8 +11,7 @@
"backToTop": "Back to top", "backToTop": "Back to top",
"settings": "Settings", "settings": "Settings",
"help": "Help", "help": "Help",
"add": "Add", "add": "Add"
"close": "Close"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "Update", "update": "Update",
"updateAvailable": "Update available", "updateAvailable": "Update available"
"skipRefresh": "Metadata refresh skipped"
}, },
"usage": { "usage": {
"timesUsed": "Times used" "timesUsed": "Times used"
@@ -184,6 +179,7 @@
"recipes": "Recipes", "recipes": "Recipes",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"misc": "Misc",
"statistics": "Stats" "statistics": "Stats"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "Search LoRAs...", "loras": "Search LoRAs...",
"recipes": "Search recipes...", "recipes": "Search recipes...",
"checkpoints": "Search checkpoints...", "checkpoints": "Search checkpoints...",
"embeddings": "Search embeddings..." "embeddings": "Search embeddings...",
"misc": "Search VAE/Upscaler models..."
}, },
"options": "Search Options", "options": "Search Options",
"searchIn": "Search In:", "searchIn": "Search In:",
@@ -228,11 +225,7 @@
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling", "allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags", "noTags": "No tags",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters"
"any": "Any",
"all": "All",
"tagLogicAny": "Match any tag (OR)",
"tagLogicAll": "Match all tags (AND)"
}, },
"theme": { "theme": {
"toggle": "Toggle theme", "toggle": "Toggle theme",
@@ -262,27 +255,17 @@
"contentFiltering": "Content Filtering", "contentFiltering": "Content Filtering",
"videoSettings": "Video Settings", "videoSettings": "Video Settings",
"layoutSettings": "Layout Settings", "layoutSettings": "Layout Settings",
"misc": "Miscellaneous", "folderSettings": "Folder Settings",
"folderSettings": "Default Roots",
"extraFolderPaths": "Extra Folder Paths",
"downloadPathTemplates": "Download Path Templates",
"priorityTags": "Priority Tags", "priorityTags": "Priority Tags",
"updateFlags": "Update Flags", "downloadPathTemplates": "Download Path Templates",
"exampleImages": "Example Images", "exampleImages": "Example Images",
"updateFlags": "Update Flags",
"autoOrganize": "Auto-organize", "autoOrganize": "Auto-organize",
"metadata": "Metadata", "misc": "Misc.",
"metadataArchive": "Metadata Archive Database",
"storageLocation": "Settings Location",
"proxySettings": "Proxy Settings" "proxySettings": "Proxy Settings"
}, },
"nav": {
"general": "General",
"interface": "Interface",
"library": "Library"
},
"search": {
"placeholder": "Search settings...",
"clear": "Clear search",
"noResults": "No settings found matching \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Portable mode", "locationLabel": "Portable mode",
"locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory." "locationHelp": "Enable to keep settings.json inside the repository; disable to store it in your user config directory."
@@ -306,15 +289,6 @@
"saveFailed": "Unable to save exclusions: {message}" "saveFailed": "Unable to save exclusions: {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "Metadata refresh skip paths",
"placeholder": "Example: temp, archived/old, test_models",
"help": "Skip models in these directory paths during bulk metadata refresh (\"Fetch All Metadata\"). Enter folder paths relative to your model root directory, separated by commas.",
"validation": {
"noPaths": "Enter at least one path separated by commas.",
"saveFailed": "Unable to save skip paths: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Display Density", "displayDensity": "Display Density",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.", "activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page.",
"loadingLibraries": "Loading libraries...", "loadingLibraries": "Loading libraries...",
"noLibraries": "No libraries configured", "noLibraries": "No libraries configured",
"defaultLoraRoot": "LoRA Root", "defaultLoraRoot": "Default LoRA Root",
"defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves", "defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves",
"defaultCheckpointRoot": "Checkpoint Root", "defaultCheckpointRoot": "Default Checkpoint Root",
"defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves", "defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves",
"defaultUnetRoot": "Diffusion Model Root", "defaultUnetRoot": "Default Diffusion Model Root",
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves", "defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
"defaultEmbeddingRoot": "Embedding Root", "defaultEmbeddingRoot": "Default Embedding Root",
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves", "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
"noDefault": "No Default" "noDefault": "No Default"
}, },
"extraFolderPaths": {
"title": "Extra Folder Paths",
"help": "Add additional model folders outside of ComfyUI's standard paths. These paths are stored separately and scanned alongside the default folders.",
"description": "Configure additional folders to scan for models. These paths are specific to LoRA Manager and will be merged with ComfyUI's default paths.",
"modelTypes": {
"lora": "LoRA Paths",
"checkpoint": "Checkpoint Paths",
"unet": "Diffusion Model Paths",
"embedding": "Embedding Paths"
},
"pathPlaceholder": "/path/to/extra/models",
"saveSuccess": "Extra folder paths updated.",
"saveError": "Failed to update extra folder paths: {message}",
"validation": {
"duplicatePath": "This path is already configured"
}
},
"priorityTags": { "priorityTags": {
"title": "Priority Tags", "title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))", "description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "Flag any available update" "any": "Flag any available update"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
},
"misc": { "misc": {
"includeTriggerWords": "Include Trigger Words in LoRA Syntax", "includeTriggerWords": "Include Trigger Words in LoRA Syntax",
"includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard" "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard"
@@ -572,12 +525,8 @@
"checkUpdates": "Check Updates for Selected", "checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder", "moveAll": "Move Selected to Folder",
"autoOrganize": "Auto-Organize Selected", "autoOrganize": "Auto-Organize Selected",
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"deleteAll": "Delete Selected Models", "deleteAll": "Delete Selected Models",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Initializing auto-organize...", "initializing": "Initializing auto-organize...",
"starting": "Starting auto-organize for {type}...", "starting": "Starting auto-organize for {type}...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "Least" "lorasCountAsc": "Least"
}, },
"refresh": { "refresh": {
"title": "Refresh recipe list", "title": "Refresh recipe list"
"quick": "Sync Changes",
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
"full": "Rebuild Cache",
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
}, },
"filteredByLora": "Filtered by LoRA", "filteredByLora": "Filtered by LoRA",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "Failed to repair recipe: {message}", "failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID" "missingId": "Cannot repair recipe: Missing recipe ID"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Embedding Models" "title": "Embedding Models"
}, },
"misc": {
"title": "VAE & Upscaler Models",
"modelTypes": {
"vae": "VAE",
"upscaler": "Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "Root", "modelRoot": "Root",
"collapseAll": "Collapse All Folders", "collapseAll": "Collapse All Folders",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "Not available in list view", "collapseAllDisabled": "Not available in list view",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.", "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found"
},
"empty": {
"noFolders": "No folders found",
"dragHint": "Drag items here to create folders"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "Untitled Version", "unnamed": "Untitled Version",
"noDetails": "No additional details", "noDetails": "No additional details"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "ending soon",
"hours": "in {count}h",
"days": "in {count}d"
}, },
"badges": { "badges": {
"current": "Current Version", "current": "Current Version",
"inLibrary": "In Library", "inLibrary": "In Library",
"newer": "Newer Version", "newer": "Newer Version",
"earlyAccess": "Early Access",
"ignored": "Ignored" "ignored": "Ignored"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "Delete", "delete": "Delete",
"ignore": "Ignore", "ignore": "Ignore",
"unignore": "Unignore", "unignore": "Unignore",
"earlyAccessTooltip": "Requires early access purchase",
"resumeModelUpdates": "Resume updates for this model", "resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model", "ignoreModelUpdates": "Ignore updates for this model",
"viewLocalVersions": "View all local versions", "viewLocalVersions": "View all local versions",
@@ -1237,6 +1116,10 @@
"title": "Initializing Statistics", "title": "Initializing Statistics",
"message": "Processing model data for statistics. This may take a few minutes..." "message": "Processing model data for statistics. This may take a few minutes..."
}, },
"misc": {
"title": "Initializing Misc Model Manager",
"message": "Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "Tips & Tricks", "title": "Tips & Tricks",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "Recipe appended to workflow", "recipeAdded": "Recipe appended to workflow",
"recipeReplaced": "Recipe replaced in workflow", "recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow", "recipeFailedToSend": "Failed to send recipe to workflow",
"vaeUpdated": "VAE updated in workflow",
"vaeFailed": "Failed to update VAE in workflow",
"upscalerUpdated": "Upscaler updated in workflow",
"upscalerFailed": "Failed to update upscaler in workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow", "noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected" "noTargetNodeSelected": "No target node selected"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
"lora": "LoRA", "lora": "LoRA",
"vae": "VAE",
"upscaler": "Upscaler",
"replace": "Replace", "replace": "Replace",
"append": "Append", "append": "Append",
"selectTargetNode": "Select target node", "selectTargetNode": "Select target node",
@@ -1418,14 +1307,7 @@
"showWechatQR": "Show WeChat QR Code", "showWechatQR": "Show WeChat QR Code",
"hideWechatQR": "Hide WeChat QR Code" "hideWechatQR": "Hide WeChat QR Code"
}, },
"footer": "Thank you for using LoRA Manager! ❤️", "footer": "Thank you for using LoRA Manager! ❤️"
"supporters": {
"title": "Thank You To Our Supporters",
"subtitle": "Thanks to {count} supporters who made this project possible",
"specialThanks": "Special Thanks",
"allSupporters": "All Supporters",
"totalCount": "{count} supporters in total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "Failed to load {modelType}s: {message}", "loadFailed": "Failed to load {modelType}s: {message}",
"refreshComplete": "Refresh complete", "refreshComplete": "Refresh complete",
"refreshFailed": "Failed to refresh recipes: {message}", "refreshFailed": "Failed to refresh recipes: {message}",
"syncComplete": "Sync complete",
"syncFailed": "Failed to sync recipes: {message}",
"updateFailed": "Failed to update recipe: {error}", "updateFailed": "Failed to update recipe: {error}",
"updateError": "Error updating recipe: {message}", "updateError": "Error updating recipe: {message}",
"nameSaved": "Recipe \"{name}\" saved successfully", "nameSaved": "Recipe \"{name}\" saved successfully",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "Failed to save recipe: {error}", "recipeSaveFailed": "Failed to save recipe: {error}",
"importFailed": "Import failed: {message}", "importFailed": "Import failed: {message}",
"folderTreeFailed": "Failed to load folder tree", "folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree", "folderTreeError": "Error loading folder tree"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "No models selected", "noModelsSelected": "No models selected",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)", "bulkBaseModelUpdateSuccess": "Successfully updated base model for {count} model(s)",
"bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)", "bulkBaseModelUpdatePartial": "Updated {success} model(s), failed {failed} model(s)",
"bulkBaseModelUpdateFailed": "Failed to update base model for selected models", "bulkBaseModelUpdateFailed": "Failed to update base model for selected models",
"skipMetadataRefreshUpdating": "Updating metadata refresh flag for {count} model(s)...",
"skipMetadataRefreshSet": "Metadata refresh skipped for {count} model(s)",
"skipMetadataRefreshCleared": "Metadata refresh resumed for {count} model(s)",
"skipMetadataRefreshPartial": "Updated {success} model(s), {failed} failed",
"skipMetadataRefreshFailed": "Failed to update metadata refresh flag for selected models",
"bulkContentRatingUpdating": "Updating content rating for {count} model(s)...", "bulkContentRatingUpdating": "Updating content rating for {count} model(s)...",
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)", "bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed", "bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "Failed to load folder tree", "folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree", "folderTreeError": "Error loading folder tree",
"imagesImported": "Example images imported successfully", "imagesImported": "Example images imported successfully",
"imagesPartial": "{success} image(s) imported, {failed} failed",
"importFailed": "Failed to import example images: {message}" "importFailed": "Failed to import example images: {message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Cache Corruption Detected"
},
"degraded": {
"title": "Cache Issues Detected"
},
"content": "{invalid} of {total} cache entries are invalid ({rate}). This may cause missing models or errors. Rebuilding the cache is recommended.",
"rebuildCache": "Rebuild Cache",
"dismiss": "Dismiss",
"rebuilding": "Rebuilding cache...",
"rebuildFailed": "Failed to rebuild cache: {error}",
"retry": "Retry"
} }
} }
} }

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar",
"delete": "Eliminar", "delete": "Eliminar",
"move": "Mover", "move": "Mover",
"refresh": "Actualizar", "refresh": "Actualizar",
@@ -14,8 +11,7 @@
"backToTop": "Volver arriba", "backToTop": "Volver arriba",
"settings": "Configuración", "settings": "Configuración",
"help": "Ayuda", "help": "Ayuda",
"add": "Añadir", "add": "Añadir"
"close": "Cerrar"
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "Actualización", "update": "Actualización",
"updateAvailable": "Actualización disponible", "updateAvailable": "Actualización disponible"
"skipRefresh": "Actualización de metadatos omitida"
}, },
"usage": { "usage": {
"timesUsed": "Veces usado" "timesUsed": "Veces usado"
@@ -184,6 +179,7 @@
"recipes": "Recetas", "recipes": "Recetas",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Estadísticas" "statistics": "Estadísticas"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "Buscar LoRAs...", "loras": "Buscar LoRAs...",
"recipes": "Buscar recetas...", "recipes": "Buscar recetas...",
"checkpoints": "Buscar checkpoints...", "checkpoints": "Buscar checkpoints...",
"embeddings": "Buscar embeddings..." "embeddings": "Buscar embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "Opciones de búsqueda", "options": "Opciones de búsqueda",
"searchIn": "Buscar en:", "searchIn": "Buscar en:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "Nombre del preajuste...", "presetNamePlaceholder": "Nombre del preajuste...",
"baseModel": "Modelo base", "baseModel": "Modelo base",
"modelTags": "Etiquetas (Top 20)", "modelTags": "Etiquetas (Top 20)",
"modelTypes": "Tipos de modelos", "modelTypes": "Model Types",
"license": "Licencia", "license": "Licencia",
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas", "noTags": "Sin etiquetas",
"clearAll": "Limpiar todos los filtros", "clearAll": "Limpiar todos los filtros"
"any": "Cualquiera",
"all": "Todos",
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
}, },
"theme": { "theme": {
"toggle": "Cambiar tema", "toggle": "Cambiar tema",
@@ -262,27 +255,17 @@
"contentFiltering": "Filtrado de contenido", "contentFiltering": "Filtrado de contenido",
"videoSettings": "Configuración de video", "videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño", "layoutSettings": "Configuración de diseño",
"misc": "Varios", "folderSettings": "Configuración de carpetas",
"folderSettings": "Raíces predeterminadas",
"extraFolderPaths": "Rutas de carpetas adicionales",
"downloadPathTemplates": "Plantillas de rutas de descarga",
"priorityTags": "Etiquetas prioritarias", "priorityTags": "Etiquetas prioritarias",
"updateFlags": "Indicadores de actualización", "downloadPathTemplates": "Plantillas de rutas de descarga",
"exampleImages": "Imágenes de ejemplo", "exampleImages": "Imágenes de ejemplo",
"autoOrganize": "Organización automática", "updateFlags": "Indicadores de actualización",
"metadata": "Metadatos", "autoOrganize": "Auto-organize",
"misc": "Varios",
"metadataArchive": "Base de datos de archivo de metadatos",
"storageLocation": "Ubicación de ajustes",
"proxySettings": "Configuración de proxy" "proxySettings": "Configuración de proxy"
}, },
"nav": {
"general": "General",
"interface": "Interfaz",
"library": "Biblioteca"
},
"search": {
"placeholder": "Buscar ajustes...",
"clear": "Limpiar búsqueda",
"noResults": "No se encontraron ajustes que coincidan con \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Modo portátil", "locationLabel": "Modo portátil",
"locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario." "locationHelp": "Activa para mantener settings.json dentro del repositorio; desactívalo para guardarlo en tu directorio de configuración de usuario."
@@ -306,15 +289,6 @@
"saveFailed": "No se pudieron guardar las exclusiones: {message}" "saveFailed": "No se pudieron guardar las exclusiones: {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "Rutas a omitir en la actualización de metadatos",
"placeholder": "Ejemplo: temp, archived/old, test_models",
"help": "Omitir modelos en estas rutas de directorio durante la actualización masiva de metadatos (\"Obtener todos los metadatos\"). Ingrese rutas de carpetas relativas al directorio raíz de modelos, separadas por comas.",
"validation": {
"noPaths": "Ingrese al menos una ruta separada por comas.",
"saveFailed": "No se pudieron guardar las rutas a omitir: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densidad de visualización", "displayDensity": "Densidad de visualización",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página.", "activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página.",
"loadingLibraries": "Cargando bibliotecas...", "loadingLibraries": "Cargando bibliotecas...",
"noLibraries": "No hay bibliotecas configuradas", "noLibraries": "No hay bibliotecas configuradas",
"defaultLoraRoot": "Raíz de LoRA", "defaultLoraRoot": "Raíz predeterminada de LoRA",
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos", "defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
"defaultCheckpointRoot": "Raíz de checkpoint", "defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
"defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos", "defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos",
"defaultUnetRoot": "Raíz de Diffusion Model", "defaultUnetRoot": "Raíz predeterminada de Diffusion Model",
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos", "defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
"defaultEmbeddingRoot": "Raíz de embedding", "defaultEmbeddingRoot": "Raíz predeterminada de embedding",
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"noDefault": "Sin predeterminado" "noDefault": "Sin predeterminado"
}, },
"extraFolderPaths": {
"title": "Rutas de carpetas adicionales",
"help": "Agregue carpetas de modelos adicionales fuera de las rutas estándar de ComfyUI. Estas rutas se almacenan por separado y se escanean junto con las carpetas predeterminadas.",
"description": "Configure carpetas adicionales para escanear modelos. Estas rutas son específicas de LoRA Manager y se fusionarán con las rutas predeterminadas de ComfyUI.",
"modelTypes": {
"lora": "Rutas de LoRA",
"checkpoint": "Rutas de Checkpoint",
"unet": "Rutas de modelo de difusión",
"embedding": "Rutas de Embedding"
},
"pathPlaceholder": "/ruta/a/modelos/extra",
"saveSuccess": "Rutas de carpetas adicionales actualizadas.",
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": {
"duplicatePath": "Esta ruta ya está configurada"
}
},
"priorityTags": { "priorityTags": {
"title": "Etiquetas prioritarias", "title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))", "description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "Marcar cualquier actualización disponible" "any": "Marcar cualquier actualización disponible"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo actualizaciones de acceso temprano"
},
"misc": { "misc": {
"includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA", "includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA",
"includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles" "includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles"
@@ -572,12 +525,8 @@
"checkUpdates": "Comprobar actualizaciones para la selección", "checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta", "moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados", "autoOrganize": "Auto-organizar seleccionados",
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"deleteAll": "Eliminar todos los modelos", "deleteAll": "Eliminar todos los modelos",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Inicializando auto-organización...", "initializing": "Inicializando auto-organización...",
"starting": "Iniciando auto-organización para {type}...", "starting": "Iniciando auto-organización para {type}...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "Menos" "lorasCountAsc": "Menos"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de recetas", "title": "Actualizar lista de recetas"
"quick": "Sincronizar cambios",
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
"full": "Reconstruir caché",
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
}, },
"filteredByLora": "Filtrado por LoRA", "filteredByLora": "Filtrado por LoRA",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "Error al reparar la receta: {message}", "failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta" "missingId": "No se puede reparar la receta: falta el ID de la receta"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Modelos embedding" "title": "Modelos embedding"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "Raíz", "modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas", "collapseAll": "Colapsar todas las carpetas",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "No disponible en vista de lista", "collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
},
"empty": {
"noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "Versión sin nombre", "unnamed": "Versión sin nombre",
"noDetails": "Sin detalles adicionales", "noDetails": "Sin detalles adicionales"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "terminando pronto",
"hours": "en {count}h",
"days": "en {count}d"
}, },
"badges": { "badges": {
"current": "Versión actual", "current": "Versión actual",
"inLibrary": "En la biblioteca", "inLibrary": "En la biblioteca",
"newer": "Versión más reciente", "newer": "Versión más reciente",
"earlyAccess": "Acceso temprano",
"ignored": "Ignorada" "ignored": "Ignorada"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "Eliminar", "delete": "Eliminar",
"ignore": "Ignorar", "ignore": "Ignorar",
"unignore": "Dejar de ignorar", "unignore": "Dejar de ignorar",
"earlyAccessTooltip": "Requiere compra de acceso temprano",
"resumeModelUpdates": "Reanudar actualizaciones para este modelo", "resumeModelUpdates": "Reanudar actualizaciones para este modelo",
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo", "ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
"viewLocalVersions": "Ver todas las versiones locales", "viewLocalVersions": "Ver todas las versiones locales",
@@ -1237,6 +1116,10 @@
"title": "Inicializando estadísticas", "title": "Inicializando estadísticas",
"message": "Procesando datos del modelo para estadísticas. Esto puede tomar unos minutos..." "message": "Procesando datos del modelo para estadísticas. Esto puede tomar unos minutos..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "Consejos y trucos", "title": "Consejos y trucos",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "Receta añadida al flujo de trabajo", "recipeAdded": "Receta añadida al flujo de trabajo",
"recipeReplaced": "Receta reemplazada en el flujo de trabajo", "recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo", "recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual", "noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino" "noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Receta", "recipe": "Receta",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Reemplazar", "replace": "Reemplazar",
"append": "Añadir", "append": "Añadir",
"selectTargetNode": "Seleccionar nodo de destino", "selectTargetNode": "Seleccionar nodo de destino",
@@ -1418,14 +1307,7 @@
"showWechatQR": "Mostrar código QR de WeChat", "showWechatQR": "Mostrar código QR de WeChat",
"hideWechatQR": "Ocultar código QR de WeChat" "hideWechatQR": "Ocultar código QR de WeChat"
}, },
"footer": "¡Gracias por usar el gestor de LoRA! ❤️", "footer": "¡Gracias por usar el gestor de LoRA! ❤️"
"supporters": {
"title": "Gracias a todos los seguidores",
"subtitle": "Gracias a {count} seguidores que hicieron este proyecto posible",
"specialThanks": "Agradecimientos especiales",
"allSupporters": "Todos los seguidores",
"totalCount": "{count} seguidores en total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "Error al cargar {modelType}s: {message}", "loadFailed": "Error al cargar {modelType}s: {message}",
"refreshComplete": "Actualización completa", "refreshComplete": "Actualización completa",
"refreshFailed": "Error al actualizar recetas: {message}", "refreshFailed": "Error al actualizar recetas: {message}",
"syncComplete": "Sincronización completa",
"syncFailed": "Error al sincronizar recetas: {message}",
"updateFailed": "Error al actualizar receta: {error}", "updateFailed": "Error al actualizar receta: {error}",
"updateError": "Error actualizando receta: {message}", "updateError": "Error actualizando receta: {message}",
"nameSaved": "Receta \"{name}\" guardada exitosamente", "nameSaved": "Receta \"{name}\" guardada exitosamente",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "Error al guardar receta: {error}", "recipeSaveFailed": "Error al guardar receta: {error}",
"importFailed": "Importación falló: {message}", "importFailed": "Importación falló: {message}",
"folderTreeFailed": "Error al cargar árbol de carpetas", "folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error cargando árbol de carpetas", "folderTreeError": "Error cargando árbol de carpetas"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "No hay modelos seleccionados", "noModelsSelected": "No hay modelos seleccionados",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)", "bulkBaseModelUpdateSuccess": "Modelo base actualizado exitosamente para {count} modelo(s)",
"bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)", "bulkBaseModelUpdatePartial": "Actualizados {success} modelo(s), fallaron {failed} modelo(s)",
"bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados", "bulkBaseModelUpdateFailed": "Error al actualizar el modelo base para los modelos seleccionados",
"skipMetadataRefreshUpdating": "Actualizando flag de actualización de metadatos para {count} modelo(s)...",
"skipMetadataRefreshSet": "Actualización de metadatos omitida para {count} modelo(s)",
"skipMetadataRefreshCleared": "Actualización de metadatos reanudada para {count} modelo(s)",
"skipMetadataRefreshPartial": "{success} modelo(s) actualizados, {failed} fallaron",
"skipMetadataRefreshFailed": "Error al actualizar flag de actualización de metadatos para los modelos seleccionados",
"bulkContentRatingUpdating": "Actualizando la clasificación de contenido para {count} modelo(s)...", "bulkContentRatingUpdating": "Actualizando la clasificación de contenido para {count} modelo(s)...",
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)", "bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron", "bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "Error al cargar árbol de carpetas", "folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error al cargar árbol de carpetas", "folderTreeError": "Error al cargar árbol de carpetas",
"imagesImported": "Imágenes de ejemplo importadas exitosamente", "imagesImported": "Imágenes de ejemplo importadas exitosamente",
"imagesPartial": "{success} imagen(es) importada(s), {failed} fallida(s)",
"importFailed": "Error al importar imágenes de ejemplo: {message}" "importFailed": "Error al importar imágenes de ejemplo: {message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Corrupción de caché detectada"
},
"degraded": {
"title": "Problemas de caché detectados"
},
"content": "{invalid} de {total} entradas de caché son inválidas ({rate}). Esto puede causar modelos faltantes o errores. Se recomienda reconstruir la caché.",
"rebuildCache": "Reconstruir caché",
"dismiss": "Descartar",
"rebuilding": "Reconstruyendo caché...",
"rebuildFailed": "Error al reconstruir la caché: {error}",
"retry": "Reintentar"
} }
} }
} }

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "Annuler",
"confirm": "Confirmer",
"actions": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Confirmer",
"delete": "Supprimer", "delete": "Supprimer",
"move": "Déplacer", "move": "Déplacer",
"refresh": "Actualiser", "refresh": "Actualiser",
@@ -14,8 +11,7 @@
"backToTop": "Retour en haut", "backToTop": "Retour en haut",
"settings": "Paramètres", "settings": "Paramètres",
"help": "Aide", "help": "Aide",
"add": "Ajouter", "add": "Ajouter"
"close": "Fermer"
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "Mise à jour", "update": "Mise à jour",
"updateAvailable": "Mise à jour disponible", "updateAvailable": "Mise à jour disponible"
"skipRefresh": "Actualisation des métadonnées ignorée"
}, },
"usage": { "usage": {
"timesUsed": "Nombre d'utilisations" "timesUsed": "Nombre d'utilisations"
@@ -184,6 +179,7 @@
"recipes": "Recipes", "recipes": "Recipes",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Statistiques" "statistics": "Statistiques"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "Rechercher des LoRAs...", "loras": "Rechercher des LoRAs...",
"recipes": "Rechercher des recipes...", "recipes": "Rechercher des recipes...",
"checkpoints": "Rechercher des checkpoints...", "checkpoints": "Rechercher des checkpoints...",
"embeddings": "Rechercher des embeddings..." "embeddings": "Rechercher des embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "Options de recherche", "options": "Options de recherche",
"searchIn": "Rechercher dans :", "searchIn": "Rechercher dans :",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "Nom du préréglage...", "presetNamePlaceholder": "Nom du préréglage...",
"baseModel": "Modèle de base", "baseModel": "Modèle de base",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Types de modèles", "modelTypes": "Model Types",
"license": "Licence", "license": "Licence",
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag", "noTags": "Aucun tag",
"clearAll": "Effacer tous les filtres", "clearAll": "Effacer tous les filtres"
"any": "N'importe quel",
"all": "Tous",
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
"tagLogicAll": "Correspondre à tous les tags (ET)"
}, },
"theme": { "theme": {
"toggle": "Basculer le thème", "toggle": "Basculer le thème",
@@ -262,27 +255,17 @@
"contentFiltering": "Filtrage du contenu", "contentFiltering": "Filtrage du contenu",
"videoSettings": "Paramètres vidéo", "videoSettings": "Paramètres vidéo",
"layoutSettings": "Paramètres d'affichage", "layoutSettings": "Paramètres d'affichage",
"misc": "Divers", "folderSettings": "Paramètres des dossiers",
"folderSettings": "Racines par défaut",
"extraFolderPaths": "Chemins de dossiers supplémentaires",
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"priorityTags": "Étiquettes prioritaires", "priorityTags": "Étiquettes prioritaires",
"updateFlags": "Indicateurs de mise à jour", "downloadPathTemplates": "Modèles de chemin de téléchargement",
"exampleImages": "Images d'exemple", "exampleImages": "Images d'exemple",
"autoOrganize": "Organisation automatique", "updateFlags": "Indicateurs de mise à jour",
"metadata": "Métadonnées", "autoOrganize": "Auto-organize",
"misc": "Divers",
"metadataArchive": "Base de données d'archive des métadonnées",
"storageLocation": "Emplacement des paramètres",
"proxySettings": "Paramètres du proxy" "proxySettings": "Paramètres du proxy"
}, },
"nav": {
"general": "Général",
"interface": "Interface",
"library": "Bibliothèque"
},
"search": {
"placeholder": "Rechercher dans les paramètres...",
"clear": "Effacer la recherche",
"noResults": "Aucun paramètre trouvé correspondant à \"{query}\""
},
"storage": { "storage": {
"locationLabel": "Mode portable", "locationLabel": "Mode portable",
"locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur." "locationHelp": "Activez pour garder settings.json dans le dépôt ; désactivez pour le placer dans votre dossier de configuration utilisateur."
@@ -306,15 +289,6 @@
"saveFailed": "Impossible d'enregistrer les exclusions : {message}" "saveFailed": "Impossible d'enregistrer les exclusions : {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "Chemins à ignorer pour l'actualisation des métadonnées",
"placeholder": "Exemple : temp, archived/old, test_models",
"help": "Ignorer les modèles dans ces chemins de répertoires lors de l'actualisation groupée des métadonnées (\"Récupérer toutes les métadonnées\"). Entrez les chemins de dossiers relatifs au répertoire racine des modèles, séparés par des virgules.",
"validation": {
"noPaths": "Entrez au moins un chemin séparé par des virgules.",
"saveFailed": "Impossible d'enregistrer les chemins à ignorer : {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Densité d'affichage", "displayDensity": "Densité d'affichage",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "Basculer entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page.", "activeLibraryHelp": "Basculer entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page.",
"loadingLibraries": "Chargement des bibliothèques...", "loadingLibraries": "Chargement des bibliothèques...",
"noLibraries": "Aucune bibliothèque configurée", "noLibraries": "Aucune bibliothèque configurée",
"defaultLoraRoot": "Racine LoRA", "defaultLoraRoot": "Racine LoRA par défaut",
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements", "defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
"defaultCheckpointRoot": "Racine Checkpoint", "defaultCheckpointRoot": "Racine Checkpoint par défaut",
"defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements", "defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements",
"defaultUnetRoot": "Racine Diffusion Model", "defaultUnetRoot": "Racine Diffusion Model par défaut",
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements", "defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
"defaultEmbeddingRoot": "Racine Embedding", "defaultEmbeddingRoot": "Racine Embedding par défaut",
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
"noDefault": "Aucun par défaut" "noDefault": "Aucun par défaut"
}, },
"extraFolderPaths": {
"title": "Chemins de dossiers supplémentaires",
"help": "Ajoutez des dossiers de modèles supplémentaires en dehors des chemins standard de ComfyUI. Ces chemins sont stockés séparément et analysés aux côtés des dossiers par défaut.",
"description": "Configurez des dossiers supplémentaires pour l'analyse de modèles. Ces chemins sont spécifiques à LoRA Manager et seront fusionnés avec les chemins par défaut de ComfyUI.",
"modelTypes": {
"lora": "Chemins LoRA",
"checkpoint": "Chemins Checkpoint",
"unet": "Chemins de modèle de diffusion",
"embedding": "Chemins Embedding"
},
"pathPlaceholder": "/chemin/vers/modèles/supplémentaires",
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour.",
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": {
"duplicatePath": "Ce chemin est déjà configuré"
}
},
"priorityTags": { "priorityTags": {
"title": "Étiquettes prioritaires", "title": "Étiquettes prioritaires",
"description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", "description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "Signaler nimporte quelle mise à jour disponible" "any": "Signaler nimporte quelle mise à jour disponible"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement les mises à jour en accès anticipé"
},
"misc": { "misc": {
"includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA", "includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA",
"includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers" "includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers"
@@ -572,12 +525,8 @@
"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",
"autoOrganize": "Auto-organiser la sélection", "autoOrganize": "Auto-organiser la sélection",
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"deleteAll": "Supprimer tous les modèles", "deleteAll": "Supprimer tous les modèles",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Initialisation de l'auto-organisation...", "initializing": "Initialisation de l'auto-organisation...",
"starting": "Démarrage de l'auto-organisation pour {type}...", "starting": "Démarrage de l'auto-organisation pour {type}...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "Moins" "lorasCountAsc": "Moins"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes", "title": "Actualiser la liste des recipes"
"quick": "Synchroniser les changements",
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
"full": "Reconstruire le cache",
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
}, },
"filteredByLora": "Filtré par LoRA", "filteredByLora": "Filtré par LoRA",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "Échec de la réparation de la recette : {message}", "failed": "Échec de la réparation de la recette : {message}",
"missingId": "Impossible de réparer la recette : ID de recette manquant" "missingId": "Impossible de réparer la recette : ID de recette manquant"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Modèles Embedding" "title": "Modèles Embedding"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "Racine", "modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers", "collapseAll": "Réduire tous les dossiers",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "Non disponible en vue liste", "collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "Relâcher pour créer un nouveau dossier",
"newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée"
},
"empty": {
"noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "Version sans nom", "unnamed": "Version sans nom",
"noDetails": "Aucun détail supplémentaire", "noDetails": "Aucun détail supplémentaire"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "se termine bientôt",
"hours": "dans {count}h",
"days": "dans {count}j"
}, },
"badges": { "badges": {
"current": "Version actuelle", "current": "Version actuelle",
"inLibrary": "Dans la bibliothèque", "inLibrary": "Dans la bibliothèque",
"newer": "Version plus récente", "newer": "Version plus récente",
"earlyAccess": "Accès anticipé",
"ignored": "Ignorée" "ignored": "Ignorée"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "Supprimer", "delete": "Supprimer",
"ignore": "Ignorer", "ignore": "Ignorer",
"unignore": "Ne plus ignorer", "unignore": "Ne plus ignorer",
"earlyAccessTooltip": "Nécessite l'achat de l'accès anticipé",
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle", "resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle", "ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
"viewLocalVersions": "Voir toutes les versions locales", "viewLocalVersions": "Voir toutes les versions locales",
@@ -1237,6 +1116,10 @@
"title": "Initialisation des statistiques", "title": "Initialisation des statistiques",
"message": "Traitement des données de modèle pour les statistiques. Cela peut prendre quelques minutes..." "message": "Traitement des données de modèle pour les statistiques. Cela peut prendre quelques minutes..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "Astuces et conseils", "title": "Astuces et conseils",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "Recipe ajoutée au workflow", "recipeAdded": "Recipe ajoutée au workflow",
"recipeReplaced": "Recipe remplacée dans le workflow", "recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow", "recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel", "noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné" "noTargetNodeSelected": "Aucun nœud cible sélectionné"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Recipe", "recipe": "Recipe",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Remplacer", "replace": "Remplacer",
"append": "Ajouter", "append": "Ajouter",
"selectTargetNode": "Sélectionner le nœud cible", "selectTargetNode": "Sélectionner le nœud cible",
@@ -1418,14 +1307,7 @@
"showWechatQR": "Afficher le QR Code WeChat", "showWechatQR": "Afficher le QR Code WeChat",
"hideWechatQR": "Masquer le QR Code WeChat" "hideWechatQR": "Masquer le QR Code WeChat"
}, },
"footer": "Merci d'utiliser le Gestionnaire LoRA ! ❤️", "footer": "Merci d'utiliser le Gestionnaire LoRA ! ❤️"
"supporters": {
"title": "Merci à tous les supporters",
"subtitle": "Merci aux {count} supporters qui ont rendu ce projet possible",
"specialThanks": "Remerciements spéciaux",
"allSupporters": "Tous les supporters",
"totalCount": "{count} supporters au total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "Échec du chargement des {modelType}s : {message}", "loadFailed": "Échec du chargement des {modelType}s : {message}",
"refreshComplete": "Actualisation terminée", "refreshComplete": "Actualisation terminée",
"refreshFailed": "Échec de l'actualisation des recipes : {message}", "refreshFailed": "Échec de l'actualisation des recipes : {message}",
"syncComplete": "Synchronisation terminée",
"syncFailed": "Échec de la synchronisation des recipes : {message}",
"updateFailed": "Échec de la mise à jour de la recipe : {error}", "updateFailed": "Échec de la mise à jour de la recipe : {error}",
"updateError": "Erreur lors de la mise à jour de la recipe : {message}", "updateError": "Erreur lors de la mise à jour de la recipe : {message}",
"nameSaved": "Recipe \"{name}\" sauvegardée avec succès", "nameSaved": "Recipe \"{name}\" sauvegardée avec succès",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}", "recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
"importFailed": "Échec de l'importation : {message}", "importFailed": "Échec de l'importation : {message}",
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers", "folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers", "folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "Aucun modèle sélectionné", "noModelsSelected": "Aucun modèle sélectionné",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)", "bulkBaseModelUpdateSuccess": "Modèle de base mis à jour avec succès pour {count} modèle(s)",
"bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec", "bulkBaseModelUpdatePartial": "{success} modèle(s) mis à jour, {failed} modèle(s) en échec",
"bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés", "bulkBaseModelUpdateFailed": "Échec de la mise à jour du modèle de base pour les modèles sélectionnés",
"skipMetadataRefreshUpdating": "Mise à jour du flag d'actualisation des métadonnées pour {count} modèle(s)...",
"skipMetadataRefreshSet": "Actualisation des métadonnées ignorée pour {count} modèle(s)",
"skipMetadataRefreshCleared": "Actualisation des métadonnées reprise pour {count} modèle(s)",
"skipMetadataRefreshPartial": "{success} modèle(s) mis à jour, {failed} échoué(s)",
"skipMetadataRefreshFailed": "Échec de la mise à jour du flag d'actualisation des métadonnées pour les modèles sélectionnés",
"bulkContentRatingUpdating": "Mise à jour de la classification du contenu pour {count} modèle(s)...", "bulkContentRatingUpdating": "Mise à jour de la classification du contenu pour {count} modèle(s)...",
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)", "bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)", "bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers", "folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers", "folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
"imagesImported": "Images d'exemple importées avec succès", "imagesImported": "Images d'exemple importées avec succès",
"imagesPartial": "{success} image(s) importée(s), {failed} échouée(s)",
"importFailed": "Échec de l'importation des images d'exemple : {message}" "importFailed": "Échec de l'importation des images d'exemple : {message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Corruption du cache détectée"
},
"degraded": {
"title": "Problèmes de cache détectés"
},
"content": "{invalid} des {total} entrées de cache sont invalides ({rate}). Cela peut provoquer des modèles manquants ou des erreurs. Il est recommandé de reconstruire le cache.",
"rebuildCache": "Reconstruire le cache",
"dismiss": "Ignorer",
"rebuilding": "Reconstruction du cache...",
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
"retry": "Réessayer"
} }
} }
} }

View File

@@ -1,21 +1,17 @@
{ {
"common": { "common": {
"cancel": "ביטול",
"confirm": "אישור",
"actions": { "actions": {
"save": "שמירה", "save": "שמור",
"cancel": "ביטול", "cancel": "ביטול",
"confirm": "אישור", "delete": "מחק",
"delete": "מחיקה", "move": עבר",
"move": "העברה", "refresh": "רענן",
"refresh": ענון", "back": "חזור",
"back": "חזרה",
"next": "הבא", "next": "הבא",
"backToTop": "חזרה למעלה", "backToTop": "חזור למעלה",
"settings": "הגדרות", "settings": "הגדרות",
"help": "עזרה", "help": "עזרה",
"add": "הוספה", "add": "הוסף"
"close": "סגור"
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "עדכון", "update": "עדכון",
"updateAvailable": "עדכון זמין", "updateAvailable": "עדכון זמין"
"skipRefresh": "רענון המטא-נתונים דולג"
}, },
"usage": { "usage": {
"timesUsed": "מספר שימושים" "timesUsed": "מספר שימושים"
@@ -184,6 +179,7 @@
"recipes": "מתכונים", "recipes": "מתכונים",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "סטטיסטיקה" "statistics": "סטטיסטיקה"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "חפש LoRAs...", "loras": "חפש LoRAs...",
"recipes": "חפש מתכונים...", "recipes": "חפש מתכונים...",
"checkpoints": "חפש checkpoints...", "checkpoints": "חפש checkpoints...",
"embeddings": "חפש embeddings..." "embeddings": "חפש embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "אפשרויות חיפוש", "options": "אפשרויות חיפוש",
"searchIn": "חפש ב:", "searchIn": "חפש ב:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "שם קביעה מראש...", "presetNamePlaceholder": "שם קביעה מראש...",
"baseModel": "מודל בסיס", "baseModel": "מודל בסיס",
"modelTags": "תגיות (20 המובילות)", "modelTags": "תגיות (20 המובילות)",
"modelTypes": "סוגי מודלים", "modelTypes": "Model Types",
"license": "רישיון", "license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות", "noTags": "ללא תגיות",
"clearAll": "נקה את כל המסננים", "clearAll": "נקה את כל המסננים"
"any": "כלשהו",
"all": "כל התגים",
"tagLogicAny": "התאם כל תג (או)",
"tagLogicAll": "התאם את כל התגים (וגם)"
}, },
"theme": { "theme": {
"toggle": "החלף ערכת נושא", "toggle": "החלף ערכת נושא",
@@ -262,27 +255,17 @@
"contentFiltering": "סינון תוכן", "contentFiltering": "סינון תוכן",
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"misc": "שונות", "folderSettings": "הגדרות תיקייה",
"folderSettings": "תיקיות ברירת מחדל",
"extraFolderPaths": "נתיבי תיקיות נוספים",
"downloadPathTemplates": "תבניות נתיב הורדה",
"priorityTags": "תגיות עדיפות", "priorityTags": "תגיות עדיפות",
"updateFlags": "תגי עדכון", "downloadPathTemplates": "תבניות נתיב הורדה",
"exampleImages": "תמונות דוגמה", "exampleImages": "תמונות דוגמה",
"autoOrganize": "ארגון אוטומטי", "updateFlags": "תגי עדכון",
"metadata": "מטא-נתונים", "autoOrganize": "Auto-organize",
"misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"storageLocation": "מיקום ההגדרות",
"proxySettings": "הגדרות פרוקסי" "proxySettings": "הגדרות פרוקסי"
}, },
"nav": {
"general": "כללי",
"interface": "ממשק",
"library": "ספרייה"
},
"search": {
"placeholder": "חיפוש בהגדרות...",
"clear": "נקה חיפוש",
"noResults": "לא נמצאו הגדרות תואמות ל-\"{query}\""
},
"storage": { "storage": {
"locationLabel": "מצב נייד", "locationLabel": "מצב נייד",
"locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש." "locationHelp": "הפעל כדי לשמור את settings.json בתוך המאגר; בטל כדי לשמור אותו בתיקיית ההגדרות של המשתמש."
@@ -306,15 +289,6 @@
"saveFailed": "לא ניתן לשמור את ההוצאות: {message}" "saveFailed": "לא ניתן לשמור את ההוצאות: {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "נתיבים לדילוג ברענון מטא-נתונים",
"placeholder": "דוגמה: temp, archived/old, test_models",
"help": "דלג על מודלים בנתיבי תיקיות אלה בעת רענון מטא-נתונים המוני (\"אחזר את כל המטא-נתונים\"). הזן נתיבי תיקיות יחסית לספריית השורש של המודל, מופרדים בפסיקים.",
"validation": {
"noPaths": "הזן לפחות נתיב אחד מופרד בפסיקים.",
"saveFailed": "לא ניתן לשמור נתיבי דילוג: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.", "activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.",
"loadingLibraries": "טוען ספריות...", "loadingLibraries": "טוען ספריות...",
"noLibraries": "לא הוגדרו ספריות", "noLibraries": "לא הוגדרו ספריות",
"defaultLoraRoot": "תיקיית שורש LoRA", "defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA",
"defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות", "defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות",
"defaultCheckpointRoot": "תיקיית שורש Checkpoint", "defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint",
"defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות", "defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות",
"defaultUnetRoot": "תיקיית שורש Diffusion Model", "defaultUnetRoot": "תיקיית שורש ברירת מחדל של Diffusion Model",
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות", "defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
"defaultEmbeddingRoot": "תיקיית שורש Embedding", "defaultEmbeddingRoot": "תיקיית שורש ברירת מחדל של Embedding",
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"noDefault": "אין ברירת מחדל" "noDefault": "אין ברירת מחדל"
}, },
"extraFolderPaths": {
"title": "נתיבי תיקיות נוספים",
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.",
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.",
"modelTypes": {
"lora": "נתיבי LoRA",
"checkpoint": "נתיבי Checkpoint",
"unet": "נתיבי מודל דיפוזיה",
"embedding": "נתיבי Embedding"
},
"pathPlaceholder": "/נתיב/למודלים/נוספים",
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": {
"duplicatePath": "נתיב זה כבר מוגדר"
}
},
"priorityTags": { "priorityTags": {
"title": "תגיות עדיפות", "title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))", "description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "תוויות לכל עדכון זמין" "any": "תוויות לכל עדכון זמין"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת"
},
"misc": { "misc": {
"includeTriggerWords": "כלול מילות טריגר בתחביר LoRA", "includeTriggerWords": "כלול מילות טריגר בתחביר LoRA",
"includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח" "includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח"
@@ -572,12 +525,8 @@
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה", "moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים", "autoOrganize": "ארגן אוטומטית נבחרים",
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"deleteAll": "מחק את כל המודלים", "deleteAll": "מחק את כל המודלים",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "מאתחל ארגון אוטומטי...", "initializing": "מאתחל ארגון אוטומטי...",
"starting": "מתחיל ארגון אוטומטי עבור {type}...", "starting": "מתחיל ארגון אוטומטי עבור {type}...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "הכי פחות" "lorasCountAsc": "הכי פחות"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים", "title": "רענן רשימת מתכונים"
"quick": "סנכרן שינויים",
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
"full": "בנה מטמון מחדש",
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
}, },
"filteredByLora": "מסונן לפי LoRA", "filteredByLora": "מסונן לפי LoRA",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "תיקון המתכון נכשל: {message}", "failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון" "missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "מודלי Embedding" "title": "מודלי Embedding"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "שורש", "modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות", "collapseAll": "כווץ את כל התיקיות",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
},
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "גרסה ללא שם", "unnamed": "גרסה ללא שם",
"noDetails": "אין פרטים נוספים", "noDetails": "אין פרטים נוספים"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "מסתיים בקרוב",
"hours": "בעוד {count} שעות",
"days": "בעוד {count} ימים"
}, },
"badges": { "badges": {
"current": "גרסה נוכחית", "current": "גרסה נוכחית",
"inLibrary": "בספרייה", "inLibrary": "בספרייה",
"newer": "גרסה חדשה יותר", "newer": "גרסה חדשה יותר",
"earlyAccess": "גישה מוקדמת",
"ignored": "התעלם" "ignored": "התעלם"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "מחיקה", "delete": "מחיקה",
"ignore": "התעלם", "ignore": "התעלם",
"unignore": "בטל התעלמות", "unignore": "בטל התעלמות",
"earlyAccessTooltip": "נדרש רכישת גישה מוקדמת",
"resumeModelUpdates": "המשך עדכונים עבור מודל זה", "resumeModelUpdates": "המשך עדכונים עבור מודל זה",
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה", "ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
"viewLocalVersions": "הצג את כל הגרסאות המקומיות", "viewLocalVersions": "הצג את כל הגרסאות המקומיות",
@@ -1237,6 +1116,10 @@
"title": "מאתחל סטטיסטיקה", "title": "מאתחל סטטיסטיקה",
"message": "מעבד נתוני מודלים עבור סטטיסטיקה. זה עשוי לקחת מספר דקות..." "message": "מעבד נתוני מודלים עבור סטטיסטיקה. זה עשוי לקחת מספר דקות..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "טיפים וטריקים", "title": "טיפים וטריקים",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "מתכון נוסף ל-workflow", "recipeAdded": "מתכון נוסף ל-workflow",
"recipeReplaced": "מתכון הוחלף ב-workflow", "recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה", "recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי", "noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד" "noTargetNodeSelected": "לא נבחר צומת יעד"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "מתכון", "recipe": "מתכון",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "החלף", "replace": "החלף",
"append": "הוסף", "append": "הוסף",
"selectTargetNode": "בחר צומת יעד", "selectTargetNode": "בחר צומת יעד",
@@ -1418,14 +1307,7 @@
"showWechatQR": "הצג קוד QR של WeChat", "showWechatQR": "הצג קוד QR של WeChat",
"hideWechatQR": "הסתר קוד QR של WeChat" "hideWechatQR": "הסתר קוד QR של WeChat"
}, },
"footer": "תודה על השימוש במנהל LoRA! ❤️", "footer": "תודה על השימוש במנהל LoRA! ❤️"
"supporters": {
"title": "תודה לכל התומכים",
"subtitle": "תודה ל־{count} תומכים שהפכו את הפרויקט הזה לאפשרי",
"specialThanks": "תודה מיוחדת",
"allSupporters": "כל התומכים",
"totalCount": "{count} תומכים בסך הכל"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "טעינת {modelType}s נכשלה: {message}", "loadFailed": "טעינת {modelType}s נכשלה: {message}",
"refreshComplete": "הרענון הושלם", "refreshComplete": "הרענון הושלם",
"refreshFailed": "רענון המתכונים נכשל: {message}", "refreshFailed": "רענון המתכונים נכשל: {message}",
"syncComplete": "הסנכרון הושלם",
"syncFailed": "סנכרון המתכונים נכשל: {message}",
"updateFailed": "עדכון המתכון נכשל: {error}", "updateFailed": "עדכון המתכון נכשל: {error}",
"updateError": "שגיאה בעדכון המתכון: {message}", "updateError": "שגיאה בעדכון המתכון: {message}",
"nameSaved": "המתכון \"{name}\" נשמר בהצלחה", "nameSaved": "המתכון \"{name}\" נשמר בהצלחה",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}", "recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
"importFailed": "הייבוא נכשל: {message}", "importFailed": "הייבוא נכשל: {message}",
"folderTreeFailed": "טעינת עץ התיקיות נכשלה", "folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות", "folderTreeError": "שגיאה בטעינת עץ התיקיות"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "לא נבחרו מודלים", "noModelsSelected": "לא נבחרו מודלים",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "עודכן בהצלחה מודל הבסיס עבור {count} מודל(ים)", "bulkBaseModelUpdateSuccess": "עודכן בהצלחה מודל הבסיס עבור {count} מודל(ים)",
"bulkBaseModelUpdatePartial": "עודכנו {success} מודל(ים), נכשלו {failed} מודל(ים)", "bulkBaseModelUpdatePartial": "עודכנו {success} מודל(ים), נכשלו {failed} מודל(ים)",
"bulkBaseModelUpdateFailed": "עדכון מודל הבסיס עבור המודלים שנבחרו נכשל", "bulkBaseModelUpdateFailed": "עדכון מודל הבסיס עבור המודלים שנבחרו נכשל",
"skipMetadataRefreshUpdating": "מעדכן דגל רענון מטא-נתונים עבור {count} מודל(ים)...",
"skipMetadataRefreshSet": "רענון מטא-נתונים דולג עבור {count} מודל(ים)",
"skipMetadataRefreshCleared": "רענון מטא-נתונים התחדש עבור {count} מודל(ים)",
"skipMetadataRefreshPartial": "{success} מודל(ים) עודכנו, {failed} נכשלו",
"skipMetadataRefreshFailed": "נכשל בעדכון דגל רענון מטא-נתונים עבור המודלים הנבחרים",
"bulkContentRatingUpdating": "מעדכן דירוג תוכן עבור {count} מודלים...", "bulkContentRatingUpdating": "מעדכן דירוג תוכן עבור {count} מודלים...",
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים", "bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו", "bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "טעינת עץ התיקיות נכשלה", "folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות", "folderTreeError": "שגיאה בטעינת עץ התיקיות",
"imagesImported": "תמונות הדוגמה יובאו בהצלחה", "imagesImported": "תמונות הדוגמה יובאו בהצלחה",
"imagesPartial": "{success} תמונה/ות יובאו, {failed} נכשלו",
"importFailed": "ייבוא תמונות הדוגמה נכשל: {message}" "importFailed": "ייבוא תמונות הדוגמה נכשל: {message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "זוהתה שחיתות במטמון"
},
"degraded": {
"title": "זוהו בעיות במטמון"
},
"content": "{invalid} מתוך {total} רשומות מטמון אינן תקינות ({rate}). זה עלול לגרום לדגמים חסרים או לשגיאות. מומלץ לבנות מחדש את המטמון.",
"rebuildCache": "בניית מטמון מחדש",
"dismiss": "ביטול",
"rebuilding": "בונה מחדש את המטמון...",
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
"retry": "נסה שוב"
} }
} }
} }

View File

@@ -1,21 +1,17 @@
{ {
"common": { "common": {
"cancel": "キャンセル",
"confirm": "確認",
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"confirm": "確認",
"delete": "削除", "delete": "削除",
"move": "移動", "move": "移動",
"refresh": "更新", "refresh": "更新",
"back": "戻る", "back": "戻る",
"next": "次へ", "next": "次へ",
"backToTop": "トップ戻る", "backToTop": "トップ戻る",
"settings": "設定", "settings": "設定",
"help": "ヘルプ", "help": "ヘルプ",
"add": "追加", "add": "追加"
"close": "閉じる"
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "アップデート", "update": "アップデート",
"updateAvailable": "アップデートがあります", "updateAvailable": "アップデートがあります"
"skipRefresh": "メタデータの更新がスキップされました"
}, },
"usage": { "usage": {
"timesUsed": "使用回数" "timesUsed": "使用回数"
@@ -184,6 +179,7 @@
"recipes": "レシピ", "recipes": "レシピ",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "LoRAを検索...", "loras": "LoRAを検索...",
"recipes": "レシピを検索...", "recipes": "レシピを検索...",
"checkpoints": "checkpointを検索...", "checkpoints": "checkpointを検索...",
"embeddings": "embeddingを検索..." "embeddings": "embeddingを検索...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "検索オプション", "options": "検索オプション",
"searchIn": "検索対象:", "searchIn": "検索対象:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "プリセット名...", "presetNamePlaceholder": "プリセット名...",
"baseModel": "ベースモデル", "baseModel": "ベースモデル",
"modelTags": "タグ上位20", "modelTags": "タグ上位20",
"modelTypes": "モデルタイプ", "modelTypes": "Model Types",
"license": "ライセンス", "license": "ライセンス",
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし", "noTags": "タグなし",
"clearAll": "すべてのフィルタをクリア", "clearAll": "すべてのフィルタをクリア"
"any": "いずれか",
"all": "すべて",
"tagLogicAny": "いずれかのタグに一致 (OR)",
"tagLogicAll": "すべてのタグに一致 (AND)"
}, },
"theme": { "theme": {
"toggle": "テーマの切り替え", "toggle": "テーマの切り替え",
@@ -262,27 +255,17 @@
"contentFiltering": "コンテンツフィルタリング", "contentFiltering": "コンテンツフィルタリング",
"videoSettings": "動画設定", "videoSettings": "動画設定",
"layoutSettings": "レイアウト設定", "layoutSettings": "レイアウト設定",
"misc": "その他", "folderSettings": "フォルダ設定",
"folderSettings": "デフォルトルート",
"extraFolderPaths": "追加フォルダーパス",
"downloadPathTemplates": "ダウンロードパステンプレート",
"priorityTags": "優先タグ", "priorityTags": "優先タグ",
"updateFlags": "アップデートフラグ", "downloadPathTemplates": "ダウンロードパステンプレート",
"exampleImages": "例画像", "exampleImages": "例画像",
"autoOrganize": "自動整理", "updateFlags": "アップデートフラグ",
"metadata": "メタデータ", "autoOrganize": "Auto-organize",
"misc": "その他",
"metadataArchive": "メタデータアーカイブデータベース",
"storageLocation": "設定の場所",
"proxySettings": "プロキシ設定" "proxySettings": "プロキシ設定"
}, },
"nav": {
"general": "一般",
"interface": "インターフェース",
"library": "ライブラリ"
},
"search": {
"placeholder": "設定を検索...",
"clear": "検索をクリア",
"noResults": "\"{query}\" に一致する設定が見つかりません"
},
"storage": { "storage": {
"locationLabel": "ポータブルモード", "locationLabel": "ポータブルモード",
"locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。" "locationHelp": "有効にすると settings.json をリポジトリ内に保持し、無効にするとユーザー設定ディレクトリに格納します。"
@@ -306,15 +289,6 @@
"saveFailed": "除外設定を保存できませんでした: {message}" "saveFailed": "除外設定を保存できませんでした: {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "メタデータ更新スキップパス",
"placeholder": "例temp, archived/old, test_models",
"help": "一括メタデータ更新(「すべてのメタデータを取得」)時にこれらのディレクトリパス内のモデルをスキップします。モデルルートディレクトリからの相対フォルダパスをカンマ区切りで入力してください。",
"validation": {
"noPaths": "カンマで区切って少なくとも1つのパスを入力してください。",
"saveFailed": "スキップパスの保存に失敗しました:{message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。", "activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。",
"loadingLibraries": "ライブラリを読み込み中...", "loadingLibraries": "ライブラリを読み込み中...",
"noLibraries": "ライブラリが設定されていません", "noLibraries": "ライブラリが設定されていません",
"defaultLoraRoot": "LoRAルート", "defaultLoraRoot": "デフォルトLoRAルート",
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定", "defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
"defaultCheckpointRoot": "Checkpointルート", "defaultCheckpointRoot": "デフォルトCheckpointルート",
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定", "defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
"defaultUnetRoot": "Diffusion Modelルート", "defaultUnetRoot": "デフォルトDiffusion Modelルート",
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定", "defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
"defaultEmbeddingRoot": "Embeddingルート", "defaultEmbeddingRoot": "デフォルトEmbeddingルート",
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
"noDefault": "デフォルトなし" "noDefault": "デフォルトなし"
}, },
"extraFolderPaths": {
"title": "追加フォルダーパス",
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。",
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。",
"modelTypes": {
"lora": "LoRAパス",
"checkpoint": "Checkpointパス",
"unet": "Diffusionモデルパス",
"embedding": "Embeddingパス"
},
"pathPlaceholder": "/追加モデルへのパス",
"saveSuccess": "追加フォルダーパスを更新しました。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": {
"duplicatePath": "このパスはすでに設定されています"
}
},
"priorityTags": { "priorityTags": {
"title": "優先タグ", "title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "利用可能な更新すべてを表示" "any": "利用可能な更新すべてを表示"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新"
},
"misc": { "misc": {
"includeTriggerWords": "LoRA構文にトリガーワードを含める", "includeTriggerWords": "LoRA構文にトリガーワードを含める",
"includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます" "includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます"
@@ -572,12 +525,8 @@
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動", "moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行", "autoOrganize": "自動整理を実行",
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"deleteAll": "すべてのモデルを削除", "deleteAll": "すべてのモデルを削除",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "自動整理を初期化中...", "initializing": "自動整理を初期化中...",
"starting": "{type}の自動整理を開始中...", "starting": "{type}の自動整理を開始中...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "少ない順" "lorasCountAsc": "少ない順"
}, },
"refresh": { "refresh": {
"title": "レシピリストを更新", "title": "レシピリストを更新"
"quick": "変更を同期",
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
"full": "キャッシュを再構築",
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
}, },
"filteredByLora": "LoRAでフィルタ済み", "filteredByLora": "LoRAでフィルタ済み",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "レシピの修復に失敗しました: {message}", "failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません" "missingId": "レシピを修復できません: レシピIDがありません"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Embeddingモデル" "title": "Embeddingモデル"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "ルート", "modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ", "collapseAll": "すべてのフォルダを折りたたむ",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。", "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません"
},
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "名前のないバージョン", "unnamed": "名前のないバージョン",
"noDetails": "追加情報なし", "noDetails": "追加情報なし"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "まもなく終了",
"hours": "{count}時間後",
"days": "{count}日後"
}, },
"badges": { "badges": {
"current": "現在のバージョン", "current": "現在のバージョン",
"inLibrary": "ライブラリにあります", "inLibrary": "ライブラリにあります",
"newer": "新しいバージョン", "newer": "新しいバージョン",
"earlyAccess": "早期アクセス",
"ignored": "無視中" "ignored": "無視中"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "削除", "delete": "削除",
"ignore": "無視", "ignore": "無視",
"unignore": "無視を解除", "unignore": "無視を解除",
"earlyAccessTooltip": "早期アクセス購入が必要",
"resumeModelUpdates": "このモデルの更新を再開", "resumeModelUpdates": "このモデルの更新を再開",
"ignoreModelUpdates": "このモデルの更新を無視", "ignoreModelUpdates": "このモデルの更新を無視",
"viewLocalVersions": "ローカルの全バージョンを表示", "viewLocalVersions": "ローカルの全バージョンを表示",
@@ -1237,6 +1116,10 @@
"title": "統計を初期化中", "title": "統計を初期化中",
"message": "統計用のモデルデータを処理中。数分かかる場合があります..." "message": "統計用のモデルデータを処理中。数分かかる場合があります..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "ヒント&コツ", "title": "ヒント&コツ",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "レシピがワークフローに追加されました", "recipeAdded": "レシピがワークフローに追加されました",
"recipeReplaced": "レシピがワークフローで置換されました", "recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした", "recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません", "noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません" "noTargetNodeSelected": "ターゲットノードが選択されていません"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "レシピ", "recipe": "レシピ",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "置換", "replace": "置換",
"append": "追加", "append": "追加",
"selectTargetNode": "ターゲットノードを選択", "selectTargetNode": "ターゲットノードを選択",
@@ -1418,14 +1307,7 @@
"showWechatQR": "WeChat QRコードを表示", "showWechatQR": "WeChat QRコードを表示",
"hideWechatQR": "WeChat QRコードを非表示" "hideWechatQR": "WeChat QRコードを非表示"
}, },
"footer": "LoRA Managerをご利用いただきありがとうございます ❤️", "footer": "LoRA Managerをご利用いただきありがとうございます ❤️"
"supporters": {
"title": "サポーターの皆様に感謝",
"subtitle": "{count} 名のサポーターの皆様に、このプロジェクトを実現していただきありがとうございます",
"specialThanks": "特別感謝",
"allSupporters": "全サポーター",
"totalCount": "サポーター {count} 名"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "{modelType}の読み込みに失敗しました:{message}", "loadFailed": "{modelType}の読み込みに失敗しました:{message}",
"refreshComplete": "更新完了", "refreshComplete": "更新完了",
"refreshFailed": "レシピの更新に失敗しました:{message}", "refreshFailed": "レシピの更新に失敗しました:{message}",
"syncComplete": "同期完了",
"syncFailed": "レシピの同期に失敗しました:{message}",
"updateFailed": "レシピの更新に失敗しました:{error}", "updateFailed": "レシピの更新に失敗しました:{error}",
"updateError": "レシピ更新エラー:{message}", "updateError": "レシピ更新エラー:{message}",
"nameSaved": "レシピ\"{name}\"が正常に保存されました", "nameSaved": "レシピ\"{name}\"が正常に保存されました",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "レシピの保存に失敗しました:{error}", "recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"importFailed": "インポートに失敗しました:{message}", "importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました", "folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー", "folderTreeError": "フォルダツリー読み込みエラー"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "モデルが選択されていません", "noModelsSelected": "モデルが選択されていません",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました", "bulkBaseModelUpdateSuccess": "{count} モデルのベースモデルが正常に更新されました",
"bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました", "bulkBaseModelUpdatePartial": "{success} モデルを更新、{failed} モデルは失敗しました",
"bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました", "bulkBaseModelUpdateFailed": "選択したモデルのベースモデルの更新に失敗しました",
"skipMetadataRefreshUpdating": "{count}モデルのメタデータ更新フラグを更新中...",
"skipMetadataRefreshSet": "{count}モデルのメタデータ更新をスキップしました",
"skipMetadataRefreshCleared": "{count}モデルのメタデータ更新を再開しました",
"skipMetadataRefreshPartial": "{success}モデルを更新しました。{failed}モデルで失敗しました",
"skipMetadataRefreshFailed": "選択したモデルのメタデータ更新フラグの更新に失敗しました",
"bulkContentRatingUpdating": "{count} 件のモデルのコンテンツレーティングを更新中...", "bulkContentRatingUpdating": "{count} 件のモデルのコンテンツレーティングを更新中...",
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました", "bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました", "bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました", "folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー", "folderTreeError": "フォルダツリー読み込みエラー",
"imagesImported": "例画像が正常にインポートされました", "imagesImported": "例画像が正常にインポートされました",
"imagesPartial": "{success} 件の画像をインポート、{failed} 件失敗",
"importFailed": "例画像のインポートに失敗しました:{message}" "importFailed": "例画像のインポートに失敗しました:{message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "キャッシュの破損が検出されました"
},
"degraded": {
"title": "キャッシュの問題が検出されました"
},
"content": "{total}個のキャッシュエントリのうち{invalid}個が無効です({rate})。モデルが見つからない原因になったり、エラーが発生する可能性があります。キャッシュの再構築を推奨します。",
"rebuildCache": "キャッシュを再構築",
"dismiss": "閉じる",
"rebuilding": "キャッシュを再構築中...",
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
"retry": "再試行"
} }
} }
} }

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "취소",
"confirm": "확인",
"actions": { "actions": {
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
"confirm": "확인",
"delete": "삭제", "delete": "삭제",
"move": "이동", "move": "이동",
"refresh": "새로고침", "refresh": "새로고침",
@@ -14,8 +11,7 @@
"backToTop": "맨 위로", "backToTop": "맨 위로",
"settings": "설정", "settings": "설정",
"help": "도움말", "help": "도움말",
"add": "추가", "add": "추가"
"close": "닫기"
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "업데이트", "update": "업데이트",
"updateAvailable": "업데이트 가능", "updateAvailable": "업데이트 가능"
"skipRefresh": "메타데이터 새로고침 건너뜀"
}, },
"usage": { "usage": {
"timesUsed": "사용 횟수" "timesUsed": "사용 횟수"
@@ -184,6 +179,7 @@
"recipes": "레시피", "recipes": "레시피",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "통계" "statistics": "통계"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "LoRA 검색...", "loras": "LoRA 검색...",
"recipes": "레시피 검색...", "recipes": "레시피 검색...",
"checkpoints": "Checkpoint 검색...", "checkpoints": "Checkpoint 검색...",
"embeddings": "Embedding 검색..." "embeddings": "Embedding 검색...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "검색 옵션", "options": "검색 옵션",
"searchIn": "검색 범위:", "searchIn": "검색 범위:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "프리셋 이름...", "presetNamePlaceholder": "프리셋 이름...",
"baseModel": "베이스 모델", "baseModel": "베이스 모델",
"modelTags": "태그 (상위 20개)", "modelTags": "태그 (상위 20개)",
"modelTypes": "모델 유형", "modelTypes": "Model Types",
"license": "라이선스", "license": "라이선스",
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음", "noTags": "태그 없음",
"clearAll": "모든 필터 지우기", "clearAll": "모든 필터 지우기"
"any": "아무",
"all": "모두",
"tagLogicAny": "모든 태그 일치 (OR)",
"tagLogicAll": "모든 태그 일치 (AND)"
}, },
"theme": { "theme": {
"toggle": "테마 토글", "toggle": "테마 토글",
@@ -262,27 +255,17 @@
"contentFiltering": "콘텐츠 필터링", "contentFiltering": "콘텐츠 필터링",
"videoSettings": "비디오 설정", "videoSettings": "비디오 설정",
"layoutSettings": "레이아웃 설정", "layoutSettings": "레이아웃 설정",
"misc": "기타", "folderSettings": "폴더 설정",
"folderSettings": "기본 루트",
"extraFolderPaths": "추가 폴다 경로",
"downloadPathTemplates": "다운로드 경로 템플릿",
"priorityTags": "우선순위 태그", "priorityTags": "우선순위 태그",
"updateFlags": "업데이트 표시", "downloadPathTemplates": "다운로드 경로 템플릿",
"exampleImages": "예시 이미지", "exampleImages": "예시 이미지",
"autoOrganize": "자동 정리", "updateFlags": "업데이트 표시",
"metadata": "메타데이터", "autoOrganize": "Auto-organize",
"misc": "기타",
"metadataArchive": "메타데이터 아카이브 데이터베이스",
"storageLocation": "설정 위치",
"proxySettings": "프록시 설정" "proxySettings": "프록시 설정"
}, },
"nav": {
"general": "일반",
"interface": "인터페이스",
"library": "라이브러리"
},
"search": {
"placeholder": "설정 검색...",
"clear": "검색 지우기",
"noResults": "\"{query}\"와 일치하는 설정을 찾을 수 없습니다"
},
"storage": { "storage": {
"locationLabel": "휴대용 모드", "locationLabel": "휴대용 모드",
"locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다." "locationHelp": "활성화하면 settings.json을 리포지토리에 유지하고, 비활성화하면 사용자 구성 디렉터리에 저장합니다."
@@ -306,15 +289,6 @@
"saveFailed": "제외 항목을 저장할 수 없습니다: {message}" "saveFailed": "제외 항목을 저장할 수 없습니다: {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "메타데이터 새로고침 건너뛰기 경로",
"placeholder": "예: temp, archived/old, test_models",
"help": "일괄 메타데이터 새로고침(\"모든 메타데이터 가져오기\") 시 이 디렉터리 경로의 모델을 건너뜁니다. 모델 루트 디렉터리를 기준으로 한 폴 더 경로를 쉼표로 구분하여 입력하세요.",
"validation": {
"noPaths": "쉼표로 구분하여 하나 이상의 경로를 입력하세요.",
"saveFailed": "건너뛰기 경로를 저장할 수 없습니다: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다.", "activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다.",
"loadingLibraries": "라이브러리를 불러오는 중...", "loadingLibraries": "라이브러리를 불러오는 중...",
"noLibraries": "구성된 라이브러리가 없습니다", "noLibraries": "구성된 라이브러리가 없습니다",
"defaultLoraRoot": "LoRA 루트", "defaultLoraRoot": "기본 LoRA 루트",
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다", "defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
"defaultCheckpointRoot": "Checkpoint 루트", "defaultCheckpointRoot": "기본 Checkpoint 루트",
"defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다", "defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다",
"defaultUnetRoot": "Diffusion Model 루트", "defaultUnetRoot": "기본 Diffusion Model 루트",
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다", "defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
"defaultEmbeddingRoot": "Embedding 루트", "defaultEmbeddingRoot": "기본 Embedding 루트",
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
"noDefault": "기본값 없음" "noDefault": "기본값 없음"
}, },
"extraFolderPaths": {
"title": "추가 폴다 경로",
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.",
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.",
"modelTypes": {
"lora": "LoRA 경로",
"checkpoint": "Checkpoint 경로",
"unet": "Diffusion 모델 경로",
"embedding": "Embedding 경로"
},
"pathPlaceholder": "/추가/모델/경로",
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
}
},
"priorityTags": { "priorityTags": {
"title": "우선순위 태그", "title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
@@ -457,10 +414,6 @@
"any": "사용 가능한 모든 업데이트 표시" "any": "사용 가능한 모든 업데이트 표시"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만"
},
"misc": { "misc": {
"includeTriggerWords": "LoRA 문법에 트리거 단어 포함", "includeTriggerWords": "LoRA 문법에 트리거 단어 포함",
"includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다" "includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다"
@@ -572,12 +525,8 @@
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동", "moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택", "autoOrganize": "자동 정리 선택",
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"deleteAll": "모든 모델 삭제", "deleteAll": "모든 모델 삭제",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "자동 정리 초기화 중...", "initializing": "자동 정리 초기화 중...",
"starting": "{type}에 대한 자동 정리 시작...", "starting": "{type}에 대한 자동 정리 시작...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "적은순" "lorasCountAsc": "적은순"
}, },
"refresh": { "refresh": {
"title": "레시피 목록 새로고침", "title": "레시피 목록 새로고침"
"quick": "변경 사항 동기화",
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
"full": "캐시 재구성",
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
}, },
"filteredByLora": "LoRA로 필터링됨", "filteredByLora": "LoRA로 필터링됨",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "레시피 복구 실패: {message}", "failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락" "missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Embedding 모델" "title": "Embedding 모델"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "루트", "modelRoot": "루트",
"collapseAll": "모든 폴더 접기", "collapseAll": "모든 폴더 접기",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
},
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "이름 없는 버전", "unnamed": "이름 없는 버전",
"noDetails": "추가 정보 없음", "noDetails": "추가 정보 없음"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "곧 종료",
"hours": "{count}시간 후",
"days": "{count}일 후"
}, },
"badges": { "badges": {
"current": "현재 버전", "current": "현재 버전",
"inLibrary": "라이브러리에 있음", "inLibrary": "라이브러리에 있음",
"newer": "최신 버전", "newer": "최신 버전",
"earlyAccess": "얼리 액세스",
"ignored": "무시됨" "ignored": "무시됨"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "삭제", "delete": "삭제",
"ignore": "무시", "ignore": "무시",
"unignore": "무시 해제", "unignore": "무시 해제",
"earlyAccessTooltip": "얼리 액세스 구매 필요",
"resumeModelUpdates": "이 모델 업데이트 재개", "resumeModelUpdates": "이 모델 업데이트 재개",
"ignoreModelUpdates": "이 모델 업데이트 무시", "ignoreModelUpdates": "이 모델 업데이트 무시",
"viewLocalVersions": "로컬 버전 모두 보기", "viewLocalVersions": "로컬 버전 모두 보기",
@@ -1237,6 +1116,10 @@
"title": "통계 초기화 중", "title": "통계 초기화 중",
"message": "통계를 위한 모델 데이터를 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..." "message": "통계를 위한 모델 데이터를 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "팁 & 요령", "title": "팁 & 요령",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "레시피가 워크플로에 추가되었습니다", "recipeAdded": "레시피가 워크플로에 추가되었습니다",
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다", "recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다", "recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다", "noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다" "noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "레시피", "recipe": "레시피",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "교체", "replace": "교체",
"append": "추가", "append": "추가",
"selectTargetNode": "대상 노드 선택", "selectTargetNode": "대상 노드 선택",
@@ -1418,14 +1307,7 @@
"showWechatQR": "WeChat QR 코드 표시", "showWechatQR": "WeChat QR 코드 표시",
"hideWechatQR": "WeChat QR 코드 숨기기" "hideWechatQR": "WeChat QR 코드 숨기기"
}, },
"footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️", "footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️"
"supporters": {
"title": "후원자 분들께 감사드립니다",
"subtitle": "이 프로젝트를 가능하게 해준 {count}명의 후원자분들께 감사드립니다",
"specialThanks": "특별 감사",
"allSupporters": "모든 후원자",
"totalCount": "총 {count}명의 후원자"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "{modelType} 로딩 실패: {message}", "loadFailed": "{modelType} 로딩 실패: {message}",
"refreshComplete": "새로고침 완료", "refreshComplete": "새로고침 완료",
"refreshFailed": "레시피 새로고침 실패: {message}", "refreshFailed": "레시피 새로고침 실패: {message}",
"syncComplete": "동기화 완료",
"syncFailed": "레시피 동기화 실패: {message}",
"updateFailed": "레시피 업데이트 실패: {error}", "updateFailed": "레시피 업데이트 실패: {error}",
"updateError": "레시피 업데이트 오류: {message}", "updateError": "레시피 업데이트 오류: {message}",
"nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다", "nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "레시피 저장 실패: {error}", "recipeSaveFailed": "레시피 저장 실패: {error}",
"importFailed": "가져오기 실패: {message}", "importFailed": "가져오기 실패: {message}",
"folderTreeFailed": "폴더 트리 로딩 실패", "folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류", "folderTreeError": "폴더 트리 로딩 오류"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "선택된 모델이 없습니다", "noModelsSelected": "선택된 모델이 없습니다",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다", "bulkBaseModelUpdateSuccess": "{count}개의 모델에 베이스 모델이 성공적으로 업데이트되었습니다",
"bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다", "bulkBaseModelUpdatePartial": "{success}개의 모델이 업데이트되었고, {failed}개의 모델이 실패했습니다",
"bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다", "bulkBaseModelUpdateFailed": "선택한 모델의 베이스 모델 업데이트에 실패했습니다",
"skipMetadataRefreshUpdating": "{count}개 모델의 메타데이터 새로고침 플래그를 업데이트하는 중...",
"skipMetadataRefreshSet": "{count}개 모델의 메타데이터 새로고침을 건너뛰었습니다",
"skipMetadataRefreshCleared": "{count}개 모델의 메타데이터 새로고침을 재개했습니다",
"skipMetadataRefreshPartial": "{success}개 모델을 업데이트했습니다. {failed}개 실패",
"skipMetadataRefreshFailed": "선택한 모델의 메타데이터 새로고침 플래그 업데이트 실패",
"bulkContentRatingUpdating": "{count}개 모델의 콘텐츠 등급을 업데이트하는 중...", "bulkContentRatingUpdating": "{count}개 모델의 콘텐츠 등급을 업데이트하는 중...",
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다", "bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다", "bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "폴더 트리 로딩 실패", "folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류", "folderTreeError": "폴더 트리 로딩 오류",
"imagesImported": "예시 이미지가 성공적으로 가져와졌습니다", "imagesImported": "예시 이미지가 성공적으로 가져와졌습니다",
"imagesPartial": "{success}개 이미지 가져오기 성공, {failed}개 실패",
"importFailed": "예시 이미지 가져오기 실패: {message}" "importFailed": "예시 이미지 가져오기 실패: {message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "캐시 손상이 감지되었습니다"
},
"degraded": {
"title": "캐시 문제가 감지되었습니다"
},
"content": "{total}개의 캐시 항목 중 {invalid}개가 유효하지 않습니다 ({rate}). 모델 누락이나 오류가 발생할 수 있습니다. 캐시를 재구축하는 것이 좋습니다.",
"rebuildCache": "캐시 재구축",
"dismiss": "무시",
"rebuilding": "캐시 재구축 중...",
"rebuildFailed": "캐시 재구축 실패: {error}",
"retry": "다시 시도"
} }
} }
} }

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"actions": { "actions": {
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена", "cancel": "Отмена",
"confirm": "Подтвердить",
"delete": "Удалить", "delete": "Удалить",
"move": "Переместить", "move": "Переместить",
"refresh": "Обновить", "refresh": "Обновить",
@@ -14,8 +11,7 @@
"backToTop": "Наверх", "backToTop": "Наверх",
"settings": "Настройки", "settings": "Настройки",
"help": "Справка", "help": "Справка",
"add": "Добавить", "add": "Добавить"
"close": "Закрыть"
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "Обновление", "update": "Обновление",
"updateAvailable": "Доступно обновление", "updateAvailable": "Доступно обновление"
"skipRefresh": "Обновление метаданных пропущено"
}, },
"usage": { "usage": {
"timesUsed": "Количество использований" "timesUsed": "Количество использований"
@@ -184,6 +179,7 @@
"recipes": "Рецепты", "recipes": "Рецепты",
"checkpoints": "Checkpoints", "checkpoints": "Checkpoints",
"embeddings": "Embeddings", "embeddings": "Embeddings",
"misc": "[TODO: Translate] Misc",
"statistics": "Статистика" "statistics": "Статистика"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "Поиск LoRAs...", "loras": "Поиск LoRAs...",
"recipes": "Поиск рецептов...", "recipes": "Поиск рецептов...",
"checkpoints": "Поиск checkpoints...", "checkpoints": "Поиск checkpoints...",
"embeddings": "Поиск embeddings..." "embeddings": "Поиск embeddings...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "Опции поиска", "options": "Опции поиска",
"searchIn": "Искать в:", "searchIn": "Искать в:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "Имя пресета...", "presetNamePlaceholder": "Имя пресета...",
"baseModel": "Базовая модель", "baseModel": "Базовая модель",
"modelTags": "Теги (Топ 20)", "modelTags": "Теги (Топ 20)",
"modelTypes": "Типы моделей", "modelTypes": "Model Types",
"license": "Лицензия", "license": "Лицензия",
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов", "noTags": "Без тегов",
"clearAll": "Очистить все фильтры", "clearAll": "Очистить все фильтры"
"any": "Любой",
"all": "Все",
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
"tagLogicAll": "Совпадение со всеми тегами (И)"
}, },
"theme": { "theme": {
"toggle": "Переключить тему", "toggle": "Переключить тему",
@@ -262,27 +255,17 @@
"contentFiltering": "Фильтрация контента", "contentFiltering": "Фильтрация контента",
"videoSettings": "Настройки видео", "videoSettings": "Настройки видео",
"layoutSettings": "Настройки макета", "layoutSettings": "Настройки макета",
"misc": "Разное", "folderSettings": "Настройки папок",
"folderSettings": "Корневые папки",
"extraFolderPaths": "Дополнительные пути к папкам",
"downloadPathTemplates": "Шаблоны путей загрузки",
"priorityTags": "Приоритетные теги", "priorityTags": "Приоритетные теги",
"updateFlags": "Метки обновлений", "downloadPathTemplates": "Шаблоны путей загрузки",
"exampleImages": "Примеры изображений", "exampleImages": "Примеры изображений",
"autoOrganize": "Автоорганизация", "updateFlags": "Метки обновлений",
"metadata": "Метаданные", "autoOrganize": "Auto-organize",
"misc": "Разное",
"metadataArchive": "Архив метаданных",
"storageLocation": "Расположение настроек",
"proxySettings": "Настройки прокси" "proxySettings": "Настройки прокси"
}, },
"nav": {
"general": "Общее",
"interface": "Интерфейс",
"library": "Библиотека"
},
"search": {
"placeholder": "Поиск в настройках...",
"clear": "Очистить поиск",
"noResults": "Настройки, соответствующие \"{query}\", не найдены"
},
"storage": { "storage": {
"locationLabel": "Портативный режим", "locationLabel": "Портативный режим",
"locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя." "locationHelp": "Включите, чтобы хранить settings.json в репозитории; выключите, чтобы сохранить его в папке конфигурации пользователя."
@@ -306,15 +289,6 @@
"saveFailed": "Не удалось сохранить исключения: {message}" "saveFailed": "Не удалось сохранить исключения: {message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "Пути для пропуска обновления метаданных",
"placeholder": "Пример: temp, archived/old, test_models",
"help": "Пропускать модели в этих каталогах при массовом обновлении метаданных («Получить все метаданные»). Введите пути к папкам относительно корневого каталога моделей, разделённые запятой.",
"validation": {
"noPaths": "Введите хотя бы один путь, разделённый запятыми.",
"saveFailed": "Не удалось сохранить пути для пропуска: {message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу.", "activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу.",
"loadingLibraries": "Загрузка библиотек...", "loadingLibraries": "Загрузка библиотек...",
"noLibraries": "Библиотеки не настроены", "noLibraries": "Библиотеки не настроены",
"defaultLoraRoot": "Корневая папка LoRA", "defaultLoraRoot": "Корневая папка LoRA по умолчанию",
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений", "defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
"defaultCheckpointRoot": "Корневая папка Checkpoint", "defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
"defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений", "defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений",
"defaultUnetRoot": "Корневая папка Diffusion Model", "defaultUnetRoot": "Корневая папка Diffusion Model по умолчанию",
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений", "defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
"defaultEmbeddingRoot": "Корневая папка Embedding", "defaultEmbeddingRoot": "Корневая папка Embedding по умолчанию",
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
"noDefault": "Не задано" "noDefault": "Не задано"
}, },
"extraFolderPaths": {
"title": "Дополнительные пути к папкам",
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.",
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.",
"modelTypes": {
"lora": "Пути LoRA",
"checkpoint": "Пути Checkpoint",
"unet": "Пути моделей диффузии",
"embedding": "Пути Embedding"
},
"pathPlaceholder": "/путь/к/дополнительным/моделям",
"saveSuccess": "Дополнительные пути к папкам обновлены.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": {
"duplicatePath": "Этот путь уже настроен"
}
},
"priorityTags": { "priorityTags": {
"title": "Приоритетные теги", "title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
@@ -457,10 +414,6 @@
"any": "Отмечать любые доступные обновления" "any": "Отмечать любые доступные обновления"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа"
},
"misc": { "misc": {
"includeTriggerWords": "Включать триггерные слова в синтаксис LoRA", "includeTriggerWords": "Включать триггерные слова в синтаксис LoRA",
"includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена" "includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена"
@@ -572,12 +525,8 @@
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку", "moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные", "autoOrganize": "Автоматически организовать выбранные",
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"deleteAll": "Удалить все модели", "deleteAll": "Удалить все модели",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "Инициализация автоматической организации...", "initializing": "Инициализация автоматической организации...",
"starting": "Запуск автоматической организации для {type}...", "starting": "Запуск автоматической организации для {type}...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "Меньше всего" "lorasCountAsc": "Меньше всего"
}, },
"refresh": { "refresh": {
"title": "Обновить список рецептов", "title": "Обновить список рецептов"
"quick": "Синхронизировать изменения",
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
"full": "Перестроить кэш",
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
}, },
"filteredByLora": "Фильтр по LoRA", "filteredByLora": "Фильтр по LoRA",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "Не удалось восстановить рецепт: {message}", "failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта" "missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Модели Embedding" "title": "Модели Embedding"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "Корень", "modelRoot": "Корень",
"collapseAll": "Свернуть все папки", "collapseAll": "Свернуть все папки",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена"
},
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "Версия без названия", "unnamed": "Версия без названия",
"noDetails": "Дополнительная информация отсутствует", "noDetails": "Дополнительная информация отсутствует"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "скоро заканчивается",
"hours": "через {count}ч",
"days": "через {count}д"
}, },
"badges": { "badges": {
"current": "Текущая версия", "current": "Текущая версия",
"inLibrary": "В библиотеке", "inLibrary": "В библиотеке",
"newer": "Более новая версия", "newer": "Более новая версия",
"earlyAccess": "Ранний доступ",
"ignored": "Игнорируется" "ignored": "Игнорируется"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "Удалить", "delete": "Удалить",
"ignore": "Игнорировать", "ignore": "Игнорировать",
"unignore": "Перестать игнорировать", "unignore": "Перестать игнорировать",
"earlyAccessTooltip": "Требуется покупка раннего доступа",
"resumeModelUpdates": "Возобновить обновления для этой модели", "resumeModelUpdates": "Возобновить обновления для этой модели",
"ignoreModelUpdates": "Игнорировать обновления для этой модели", "ignoreModelUpdates": "Игнорировать обновления для этой модели",
"viewLocalVersions": "Показать все локальные версии", "viewLocalVersions": "Показать все локальные версии",
@@ -1237,6 +1116,10 @@
"title": "Инициализация статистики", "title": "Инициализация статистики",
"message": "Обработка данных моделей для статистики. Это может занять несколько минут..." "message": "Обработка данных моделей для статистики. Это может занять несколько минут..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "Советы и хитрости", "title": "Советы и хитрости",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "Рецепт добавлен в workflow", "recipeAdded": "Рецепт добавлен в workflow",
"recipeReplaced": "Рецепт заменён в workflow", "recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow", "recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов", "noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран" "noTargetNodeSelected": "Целевой узел не выбран"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "Рецепт", "recipe": "Рецепт",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "Заменить", "replace": "Заменить",
"append": "Добавить", "append": "Добавить",
"selectTargetNode": "Выберите целевой узел", "selectTargetNode": "Выберите целевой узел",
@@ -1418,14 +1307,7 @@
"showWechatQR": "Показать QR-код WeChat", "showWechatQR": "Показать QR-код WeChat",
"hideWechatQR": "Скрыть QR-код WeChat" "hideWechatQR": "Скрыть QR-код WeChat"
}, },
"footer": "Спасибо за использование LoRA Manager! ❤️", "footer": "Спасибо за использование LoRA Manager! ❤️"
"supporters": {
"title": "Спасибо всем сторонникам",
"subtitle": "Спасибо {count} сторонникам, которые сделали этот проект возможным",
"specialThanks": "Особая благодарность",
"allSupporters": "Все сторонники",
"totalCount": "Всего {count} сторонников"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "Не удалось загрузить {modelType}s: {message}", "loadFailed": "Не удалось загрузить {modelType}s: {message}",
"refreshComplete": "Обновление завершено", "refreshComplete": "Обновление завершено",
"refreshFailed": "Не удалось обновить рецепты: {message}", "refreshFailed": "Не удалось обновить рецепты: {message}",
"syncComplete": "Синхронизация завершена",
"syncFailed": "Не удалось синхронизировать рецепты: {message}",
"updateFailed": "Не удалось обновить рецепт: {error}", "updateFailed": "Не удалось обновить рецепт: {error}",
"updateError": "Ошибка обновления рецепта: {message}", "updateError": "Ошибка обновления рецепта: {message}",
"nameSaved": "Рецепт \"{name}\" успешно сохранен", "nameSaved": "Рецепт \"{name}\" успешно сохранен",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}", "recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
"importFailed": "Импорт не удался: {message}", "importFailed": "Импорт не удался: {message}",
"folderTreeFailed": "Не удалось загрузить дерево папок", "folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок", "folderTreeError": "Ошибка загрузки дерева папок"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "Модели не выбраны", "noModelsSelected": "Модели не выбраны",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей", "bulkBaseModelUpdateSuccess": "Базовая модель успешно обновлена для {count} моделей",
"bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей", "bulkBaseModelUpdatePartial": "Обновлено {success} моделей, не удалось обновить {failed} моделей",
"bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей", "bulkBaseModelUpdateFailed": "Не удалось обновить базовую модель для выбранных моделей",
"skipMetadataRefreshUpdating": "Обновление флага обновления метаданных для {count} модели(ей)...",
"skipMetadataRefreshSet": "Обновление метаданных пропущено для {count} модели(ей)",
"skipMetadataRefreshCleared": "Обновление метаданных возобновлено для {count} модели(ей)",
"skipMetadataRefreshPartial": "{success} модели(ей) обновлено, {failed} не удалось",
"skipMetadataRefreshFailed": "Не удалось обновить флаг обновления метаданных для выбранных моделей",
"bulkContentRatingUpdating": "Обновление рейтинга контента для {count} модель(ей)...", "bulkContentRatingUpdating": "Обновление рейтинга контента для {count} модель(ей)...",
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)", "bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось", "bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "Не удалось загрузить дерево папок", "folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок", "folderTreeError": "Ошибка загрузки дерева папок",
"imagesImported": "Примеры изображений успешно импортированы", "imagesImported": "Примеры изображений успешно импортированы",
"imagesPartial": "{success} изображ. импортировано, {failed} не удалось",
"importFailed": "Не удалось импортировать примеры изображений: {message}" "importFailed": "Не удалось импортировать примеры изображений: {message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "Обнаружено повреждение кэша"
},
"degraded": {
"title": "Обнаружены проблемы с кэшем"
},
"content": "{invalid} из {total} записей кэша недействительны ({rate}). Это может привести к отсутствию моделей или ошибкам. Рекомендуется перестроить кэш.",
"rebuildCache": "Перестроить кэш",
"dismiss": "Отклонить",
"rebuilding": "Перестроение кэша...",
"rebuildFailed": "Не удалось перестроить кэш: {error}",
"retry": "Повторить"
} }
} }
} }

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "取消",
"confirm": "确认",
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"confirm": "确认",
"delete": "删除", "delete": "删除",
"move": "移动", "move": "移动",
"refresh": "刷新", "refresh": "刷新",
@@ -14,8 +11,7 @@
"backToTop": "返回顶部", "backToTop": "返回顶部",
"settings": "设置", "settings": "设置",
"help": "帮助", "help": "帮助",
"add": "添加", "add": "添加"
"close": "关闭"
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "更新", "update": "更新",
"updateAvailable": "有可用更新", "updateAvailable": "有可用更新"
"skipRefresh": "元数据刷新已跳过"
}, },
"usage": { "usage": {
"timesUsed": "使用次数" "timesUsed": "使用次数"
@@ -163,11 +158,11 @@
"error": "清理示例图片文件夹失败:{message}" "error": "清理示例图片文件夹失败:{message}"
}, },
"fetchMissingLicenses": { "fetchMissingLicenses": {
"label": "刷新许可证元数据", "label": "Refresh license metadata",
"loading": "正在刷新 {typePlural} 的许可证元数据...", "loading": "Refreshing license metadata for {typePlural}...",
"success": "已更新 {count} {typePlural} 的许可证元数据", "success": "Updated license metadata for {count} {typePlural}",
"none": "所有 {typePlural} 都已具备许可证元数据", "none": "All {typePlural} already have license metadata",
"error": "刷新 {typePlural} 的许可证元数据失败:{message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
}, },
"repairRecipes": { "repairRecipes": {
"label": "修复配方数据", "label": "修复配方数据",
@@ -184,6 +179,7 @@
"recipes": "配方", "recipes": "配方",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "统计" "statistics": "统计"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "搜索 LoRA...", "loras": "搜索 LoRA...",
"recipes": "搜索配方...", "recipes": "搜索配方...",
"checkpoints": "搜索 Checkpoint...", "checkpoints": "搜索 Checkpoint...",
"embeddings": "搜索 Embedding..." "embeddings": "搜索 Embedding...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "搜索选项", "options": "搜索选项",
"searchIn": "搜索范围:", "searchIn": "搜索范围:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "预设名称...", "presetNamePlaceholder": "预设名称...",
"baseModel": "基础模型", "baseModel": "基础模型",
"modelTags": "标签前20", "modelTags": "标签前20",
"modelTypes": "模型类型", "modelTypes": "Model Types",
"license": "许可证", "license": "许可证",
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
"noTags": "无标签", "noTags": "无标签",
"clearAll": "清除所有筛选", "clearAll": "清除所有筛选"
"any": "任一",
"all": "全部",
"tagLogicAny": "匹配任一标签 (或)",
"tagLogicAll": "匹配所有标签 (与)"
}, },
"theme": { "theme": {
"toggle": "切换主题", "toggle": "切换主题",
@@ -262,27 +255,17 @@
"contentFiltering": "内容过滤", "contentFiltering": "内容过滤",
"videoSettings": "视频设置", "videoSettings": "视频设置",
"layoutSettings": "布局设置", "layoutSettings": "布局设置",
"misc": "其他", "folderSettings": "文件夹设置",
"folderSettings": "默认根目录",
"extraFolderPaths": "额外文件夹路径",
"downloadPathTemplates": "下载路径模板",
"priorityTags": "优先标签", "priorityTags": "优先标签",
"updateFlags": "更新标记", "downloadPathTemplates": "下载路径模板",
"exampleImages": "示例图片", "exampleImages": "示例图片",
"autoOrganize": "自动整理", "updateFlags": "更新标记",
"metadata": "元数据", "autoOrganize": "Auto-organize",
"misc": "其他",
"metadataArchive": "元数据归档数据库",
"storageLocation": "设置位置",
"proxySettings": "代理设置" "proxySettings": "代理设置"
}, },
"nav": {
"general": "通用",
"interface": "界面",
"library": "库"
},
"search": {
"placeholder": "搜索设置...",
"clear": "清除搜索",
"noResults": "未找到匹配 \"{query}\" 的设置"
},
"storage": { "storage": {
"locationLabel": "便携模式", "locationLabel": "便携模式",
"locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。" "locationHelp": "开启可将 settings.json 保存在仓库中;关闭则保存在用户配置目录。"
@@ -306,15 +289,6 @@
"saveFailed": "无法保存排除项:{message}" "saveFailed": "无法保存排除项:{message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "元数据刷新跳过路径",
"placeholder": "示例temp, archived/old, test_models",
"help": "批量刷新元数据(\"获取全部元数据\")时跳过这些目录路径中的模型。输入相对于模型根目录的文件夹路径,以逗号分隔。",
"validation": {
"noPaths": "请输入至少一个路径,以逗号分隔。",
"saveFailed": "无法保存跳过路径:{message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。", "activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。",
"loadingLibraries": "正在加载库...", "loadingLibraries": "正在加载库...",
"noLibraries": "尚未配置库", "noLibraries": "尚未配置库",
"defaultLoraRoot": "LoRA 根目录", "defaultLoraRoot": "默认 LoRA 根目录",
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录", "defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
"defaultCheckpointRoot": "Checkpoint 根目录", "defaultCheckpointRoot": "默认 Checkpoint 根目录",
"defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录", "defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录",
"defaultUnetRoot": "Diffusion Model 根目录", "defaultUnetRoot": "默认 Diffusion Model 根目录",
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录", "defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
"defaultEmbeddingRoot": "Embedding 根目录", "defaultEmbeddingRoot": "默认 Embedding 根目录",
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
"noDefault": "无默认" "noDefault": "无默认"
}, },
"extraFolderPaths": {
"title": "额外文件夹路径",
"help": "在 ComfyUI 的标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。",
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。",
"modelTypes": {
"lora": "LoRA 路径",
"checkpoint": "Checkpoint 路径",
"unet": "Diffusion 模型路径",
"embedding": "Embedding 路径"
},
"pathPlaceholder": "/额外/模型/路径",
"saveSuccess": "额外文件夹路径已更新。",
"saveError": "更新额外文件夹路径失败:{message}",
"validation": {
"duplicatePath": "此路径已配置"
}
},
"priorityTags": { "priorityTags": {
"title": "优先标签", "title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "显示任何可用更新" "any": "显示任何可用更新"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "隐藏抢先体验更新",
"help": "抢先体验更新"
},
"misc": { "misc": {
"includeTriggerWords": "复制 LoRA 语法时包含触发词", "includeTriggerWords": "复制 LoRA 语法时包含触发词",
"includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词" "includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词"
@@ -572,12 +525,8 @@
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹", "moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型", "autoOrganize": "自动整理所选模型",
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"deleteAll": "删除选中模型", "deleteAll": "删除选中模型",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "正在初始化自动整理...", "initializing": "正在初始化自动整理...",
"starting": "正在为 {type} 启动自动整理...", "starting": "正在为 {type} 启动自动整理...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "最少" "lorasCountAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "刷新配方列表", "title": "刷新配方列表"
"quick": "同步变更",
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
"full": "重建缓存",
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
}, },
"filteredByLora": "按 LoRA 筛选", "filteredByLora": "按 LoRA 筛选",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "修复配方失败:{message}", "failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID" "missingId": "无法修复配方:缺少配方 ID"
} }
},
"batchImport": {
"title": "批量导入配方",
"action": "批量导入",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"urlsLabel": "图片 URL 或本地路径",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "/图片/文件夹/路径",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "标签(可选,应用于所有配方)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "跳过无元数据的图片",
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
"start": "[TODO: Translate] Start Import",
"startImport": "开始导入",
"importing": "正在导入配方...",
"progress": "进度",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "当前",
"preparing": "准备中...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "取消",
"cancelled": "批量导入已取消",
"completed": "导入完成",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "成功导入 {count} 个配方",
"successCount": "成功",
"failedCount": "失败",
"skippedCount": "跳过",
"totalProcessed": "总计处理",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "请至少输入一个 URL 或路径",
"enterDirectory": "请输入目录路径",
"startFailed": "启动导入失败:{message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Embedding 模型" "title": "Embedding 模型"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "根目录", "modelRoot": "根目录",
"collapseAll": "折叠所有文件夹", "collapseAll": "折叠所有文件夹",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。", "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "Move is not supported for this item.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认Escape 取消",
"emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作"
},
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "未命名版本", "unnamed": "未命名版本",
"noDetails": "暂无更多信息", "noDetails": "暂无更多信息"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "即将结束",
"hours": "{count}小时后",
"days": "{count}天后"
}, },
"badges": { "badges": {
"current": "当前版本", "current": "当前版本",
"inLibrary": "已在库中", "inLibrary": "已在库中",
"newer": "较新的版本", "newer": "较新的版本",
"earlyAccess": "抢先体验",
"ignored": "已忽略" "ignored": "已忽略"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "删除", "delete": "删除",
"ignore": "忽略", "ignore": "忽略",
"unignore": "取消忽略", "unignore": "取消忽略",
"earlyAccessTooltip": "需要购买抢先体验",
"resumeModelUpdates": "继续跟踪该模型的更新", "resumeModelUpdates": "继续跟踪该模型的更新",
"ignoreModelUpdates": "忽略该模型的更新", "ignoreModelUpdates": "忽略该模型的更新",
"viewLocalVersions": "查看所有本地版本", "viewLocalVersions": "查看所有本地版本",
@@ -1237,6 +1116,10 @@
"title": "初始化统计", "title": "初始化统计",
"message": "正在处理模型数据以生成统计信息。这可能需要几分钟..." "message": "正在处理模型数据以生成统计信息。这可能需要几分钟..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "技巧与提示", "title": "技巧与提示",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "配方已追加到工作流", "recipeAdded": "配方已追加到工作流",
"recipeReplaced": "配方已替换到工作流", "recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败", "recipeFailedToSend": "发送配方到工作流失败",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "当前工作流中没有兼容的节点", "noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点" "noTargetNodeSelected": "未选择目标节点"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "替换", "replace": "替换",
"append": "追加", "append": "追加",
"selectTargetNode": "选择目标节点", "selectTargetNode": "选择目标节点",
@@ -1418,14 +1307,7 @@
"showWechatQR": "显示微信二维码", "showWechatQR": "显示微信二维码",
"hideWechatQR": "隐藏微信二维码" "hideWechatQR": "隐藏微信二维码"
}, },
"footer": "感谢使用 LoRA 管理器!❤️", "footer": "感谢使用 LoRA 管理器!❤️"
"supporters": {
"title": "感谢所有支持者",
"subtitle": "感谢 {count} 位支持者让这个项目成为可能",
"specialThanks": "特别感谢",
"allSupporters": "所有支持者",
"totalCount": "共 {count} 位支持者"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "加载 {modelType} 失败:{message}", "loadFailed": "加载 {modelType} 失败:{message}",
"refreshComplete": "刷新完成", "refreshComplete": "刷新完成",
"refreshFailed": "刷新配方失败:{message}", "refreshFailed": "刷新配方失败:{message}",
"syncComplete": "同步完成",
"syncFailed": "同步配方失败:{message}",
"updateFailed": "更新配方失败:{error}", "updateFailed": "更新配方失败:{error}",
"updateError": "更新配方出错:{message}", "updateError": "更新配方出错:{message}",
"nameSaved": "配方“{name}”保存成功", "nameSaved": "配方“{name}”保存成功",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "保存配方失败:{error}", "recipeSaveFailed": "保存配方失败:{error}",
"importFailed": "导入失败:{message}", "importFailed": "导入失败:{message}",
"folderTreeFailed": "加载文件夹树失败", "folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错", "folderTreeError": "加载文件夹树出错"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "未选中模型", "noModelsSelected": "未选中模型",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型", "bulkBaseModelUpdateSuccess": "成功为 {count} 个模型更新基础模型",
"bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败", "bulkBaseModelUpdatePartial": "更新了 {success} 个模型,{failed} 个失败",
"bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败", "bulkBaseModelUpdateFailed": "为选中模型更新基础模型失败",
"skipMetadataRefreshUpdating": "正在更新 {count} 个模型的元数据刷新标志...",
"skipMetadataRefreshSet": "已为 {count} 个模型跳过元数据刷新",
"skipMetadataRefreshCleared": "已为 {count} 个模型恢复元数据刷新",
"skipMetadataRefreshPartial": "已更新 {success} 个模型,{failed} 个失败",
"skipMetadataRefreshFailed": "未能更新所选模型的元数据刷新标志",
"bulkContentRatingUpdating": "正在为 {count} 个模型更新内容评级...", "bulkContentRatingUpdating": "正在为 {count} 个模型更新内容评级...",
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}", "bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败", "bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "加载文件夹树失败", "folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错", "folderTreeError": "加载文件夹树出错",
"imagesImported": "示例图片导入成功", "imagesImported": "示例图片导入成功",
"imagesPartial": "成功导入 {success} 张图片,{failed} 张失败",
"importFailed": "导入示例图片失败:{message}" "importFailed": "导入示例图片失败:{message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "来爱发电为Lora Manager项目发电支持项目持续开发的同时获取浏览器插件验证码按季支付更优惠支付宝/微信方便支付。感谢支持!🚀", "content": "来爱发电为Lora Manager项目发电支持项目持续开发的同时获取浏览器插件验证码按季支付更优惠支付宝/微信方便支付。感谢支持!🚀",
"supportCta": "为LM发电", "supportCta": "为LM发电",
"learnMore": "浏览器插件教程" "learnMore": "浏览器插件教程"
},
"cacheHealth": {
"corrupted": {
"title": "检测到缓存损坏"
},
"degraded": {
"title": "检测到缓存问题"
},
"content": "{total} 个缓存条目中有 {invalid} 个无效({rate})。这可能导致模型丢失或错误。建议重建缓存。",
"rebuildCache": "重建缓存",
"dismiss": "忽略",
"rebuilding": "正在重建缓存...",
"rebuildFailed": "重建缓存失败:{error}",
"retry": "重试"
} }
} }
} }

View File

@@ -1,11 +1,8 @@
{ {
"common": { "common": {
"cancel": "取消",
"confirm": "確認",
"actions": { "actions": {
"save": "儲存", "save": "儲存",
"cancel": "取消", "cancel": "取消",
"confirm": "確認",
"delete": "刪除", "delete": "刪除",
"move": "移動", "move": "移動",
"refresh": "重新整理", "refresh": "重新整理",
@@ -14,8 +11,7 @@
"backToTop": "回到頂部", "backToTop": "回到頂部",
"settings": "設定", "settings": "設定",
"help": "說明", "help": "說明",
"add": "新增", "add": "新增"
"close": "關閉"
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
@@ -135,8 +131,7 @@
}, },
"badges": { "badges": {
"update": "更新", "update": "更新",
"updateAvailable": "有可用更新", "updateAvailable": "有可用更新"
"skipRefresh": "元數據更新已跳過"
}, },
"usage": { "usage": {
"timesUsed": "使用次數" "timesUsed": "使用次數"
@@ -184,6 +179,7 @@
"recipes": "配方", "recipes": "配方",
"checkpoints": "Checkpoint", "checkpoints": "Checkpoint",
"embeddings": "Embedding", "embeddings": "Embedding",
"misc": "[TODO: Translate] Misc",
"statistics": "統計" "statistics": "統計"
}, },
"search": { "search": {
@@ -192,7 +188,8 @@
"loras": "搜尋 LoRA...", "loras": "搜尋 LoRA...",
"recipes": "搜尋配方...", "recipes": "搜尋配方...",
"checkpoints": "搜尋 checkpoint...", "checkpoints": "搜尋 checkpoint...",
"embeddings": "搜尋 embedding..." "embeddings": "搜尋 embedding...",
"misc": "[TODO: Translate] Search VAE/Upscaler models..."
}, },
"options": "搜尋選項", "options": "搜尋選項",
"searchIn": "搜尋範圍:", "searchIn": "搜尋範圍:",
@@ -223,16 +220,12 @@
"presetNamePlaceholder": "預設名稱...", "presetNamePlaceholder": "預設名稱...",
"baseModel": "基礎模型", "baseModel": "基礎模型",
"modelTags": "標籤(前 20", "modelTags": "標籤(前 20",
"modelTypes": "模型類型", "modelTypes": "Model Types",
"license": "授權", "license": "授權",
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤", "noTags": "無標籤",
"clearAll": "清除所有篩選", "clearAll": "清除所有篩選"
"any": "任一",
"all": "全部",
"tagLogicAny": "符合任一票籤 (或)",
"tagLogicAll": "符合所有標籤 (與)"
}, },
"theme": { "theme": {
"toggle": "切換主題", "toggle": "切換主題",
@@ -262,27 +255,17 @@
"contentFiltering": "內容過濾", "contentFiltering": "內容過濾",
"videoSettings": "影片設定", "videoSettings": "影片設定",
"layoutSettings": "版面設定", "layoutSettings": "版面設定",
"misc": "其他", "folderSettings": "資料夾設定",
"folderSettings": "預設根目錄",
"extraFolderPaths": "額外資料夾路徑",
"downloadPathTemplates": "下載路徑範本",
"priorityTags": "優先標籤", "priorityTags": "優先標籤",
"updateFlags": "更新標記", "downloadPathTemplates": "下載路徑範本",
"exampleImages": "範例圖片", "exampleImages": "範例圖片",
"autoOrganize": "自動整理", "updateFlags": "更新標記",
"metadata": "中繼資料", "autoOrganize": "Auto-organize",
"misc": "其他",
"metadataArchive": "中繼資料封存資料庫",
"storageLocation": "設定位置",
"proxySettings": "代理設定" "proxySettings": "代理設定"
}, },
"nav": {
"general": "通用",
"interface": "介面",
"library": "模型庫"
},
"search": {
"placeholder": "搜尋設定...",
"clear": "清除搜尋",
"noResults": "未找到符合 \"{query}\" 的設定"
},
"storage": { "storage": {
"locationLabel": "可攜式模式", "locationLabel": "可攜式模式",
"locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。" "locationHelp": "啟用可將 settings.json 保存在儲存庫中;停用則保存在使用者設定目錄。"
@@ -306,15 +289,6 @@
"saveFailed": "無法儲存排除項目:{message}" "saveFailed": "無法儲存排除項目:{message}"
} }
}, },
"metadataRefreshSkipPaths": {
"label": "中繼資料重新整理跳過路徑",
"placeholder": "範例temp, archived/old, test_models",
"help": "批次重新整理中繼資料(「擷取所有中繼資料」)時跳過這些目錄路徑中的模型。輸入相對於模型根目錄的資料夾路徑,以逗號分隔。",
"validation": {
"noPaths": "請輸入至少一個路徑,以逗號分隔。",
"saveFailed": "無法儲存跳過路徑:{message}"
}
},
"layoutSettings": { "layoutSettings": {
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
@@ -355,33 +329,16 @@
"activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項會重新載入頁面。", "activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項會重新載入頁面。",
"loadingLibraries": "正在載入資料庫...", "loadingLibraries": "正在載入資料庫...",
"noLibraries": "尚未設定任何資料庫", "noLibraries": "尚未設定任何資料庫",
"defaultLoraRoot": "LoRA 根目錄", "defaultLoraRoot": "預設 LoRA 根目錄",
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄", "defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
"defaultCheckpointRoot": "Checkpoint 根目錄", "defaultCheckpointRoot": "預設 Checkpoint 根目錄",
"defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄", "defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄",
"defaultUnetRoot": "Diffusion Model 根目錄", "defaultUnetRoot": "預設 Diffusion Model 根目錄",
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄", "defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
"defaultEmbeddingRoot": "Embedding 根目錄", "defaultEmbeddingRoot": "預設 Embedding 根目錄",
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
"noDefault": "未設定預設" "noDefault": "未設定預設"
}, },
"extraFolderPaths": {
"title": "額外資料夾路徑",
"help": "在 ComfyUI 的標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。",
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。",
"modelTypes": {
"lora": "LoRA 路徑",
"checkpoint": "Checkpoint 路徑",
"unet": "Diffusion 模型路徑",
"embedding": "Embedding 路徑"
},
"pathPlaceholder": "/額外/模型/路徑",
"saveSuccess": "額外資料夾路徑已更新。",
"saveError": "更新額外資料夾路徑失敗:{message}",
"validation": {
"duplicatePath": "此路徑已設定"
}
},
"priorityTags": { "priorityTags": {
"title": "優先標籤", "title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
@@ -457,10 +414,6 @@
"any": "顯示任何可用更新" "any": "顯示任何可用更新"
} }
}, },
"hideEarlyAccessUpdates": {
"label": "隱藏搶先體驗更新",
"help": "搶先體驗更新"
},
"misc": { "misc": {
"includeTriggerWords": "在 LoRA 語法中包含觸發詞", "includeTriggerWords": "在 LoRA 語法中包含觸發詞",
"includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞" "includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞"
@@ -572,12 +525,8 @@
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾", "moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型", "autoOrganize": "自動整理所選模型",
"skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
"deleteAll": "刪除全部模型", "deleteAll": "刪除全部模型",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
"autoOrganizeProgress": { "autoOrganizeProgress": {
"initializing": "正在初始化自動整理...", "initializing": "正在初始化自動整理...",
"starting": "正在開始自動整理 {type}...", "starting": "正在開始自動整理 {type}...",
@@ -686,11 +635,7 @@
"lorasCountAsc": "最少" "lorasCountAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "重新整理配方列表", "title": "重新整理配方列表"
"quick": "同步變更",
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
"full": "重建快取",
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
}, },
"filteredByLora": "已依 LoRA 篩選", "filteredByLora": "已依 LoRA 篩選",
"favorites": { "favorites": {
@@ -730,64 +675,6 @@
"failed": "修復配方失敗:{message}", "failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID" "missingId": "無法修復配方:缺少配方 ID"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -803,6 +690,16 @@
"embeddings": { "embeddings": {
"title": "Embedding 模型" "title": "Embedding 模型"
}, },
"misc": {
"title": "[TODO: Translate] VAE & Upscaler Models",
"modelTypes": {
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler"
},
"contextMenu": {
"moveToOtherTypeFolder": "[TODO: Translate] Move to {otherType} Folder"
}
},
"sidebar": { "sidebar": {
"modelRoot": "根目錄", "modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾", "collapseAll": "全部摺疊資料夾",
@@ -816,17 +713,7 @@
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。", "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "Move is not supported for this item.", "moveUnsupported": "Move is not supported for this item."
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認Escape 取消",
"emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作"
},
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾"
} }
}, },
"statistics": { "statistics": {
@@ -1138,19 +1025,12 @@
}, },
"labels": { "labels": {
"unnamed": "未命名版本", "unnamed": "未命名版本",
"noDetails": "沒有其他資訊", "noDetails": "沒有其他資訊"
"earlyAccess": "EA"
},
"eaTime": {
"endingSoon": "即將結束",
"hours": "{count}小時後",
"days": "{count}天後"
}, },
"badges": { "badges": {
"current": "目前版本", "current": "目前版本",
"inLibrary": "已在庫中", "inLibrary": "已在庫中",
"newer": "較新版本", "newer": "較新版本",
"earlyAccess": "搶先體驗",
"ignored": "已忽略" "ignored": "已忽略"
}, },
"actions": { "actions": {
@@ -1158,7 +1038,6 @@
"delete": "刪除", "delete": "刪除",
"ignore": "忽略", "ignore": "忽略",
"unignore": "取消忽略", "unignore": "取消忽略",
"earlyAccessTooltip": "需要購買搶先體驗",
"resumeModelUpdates": "恢復追蹤此模型的更新", "resumeModelUpdates": "恢復追蹤此模型的更新",
"ignoreModelUpdates": "忽略此模型的更新", "ignoreModelUpdates": "忽略此模型的更新",
"viewLocalVersions": "檢視所有本地版本", "viewLocalVersions": "檢視所有本地版本",
@@ -1237,6 +1116,10 @@
"title": "初始化統計", "title": "初始化統計",
"message": "正在處理模型資料以產生統計,可能需要幾分鐘..." "message": "正在處理模型資料以產生統計,可能需要幾分鐘..."
}, },
"misc": {
"title": "[TODO: Translate] Initializing Misc Model Manager",
"message": "[TODO: Translate] Scanning VAE and Upscaler models..."
},
"tips": { "tips": {
"title": "小技巧", "title": "小技巧",
"civitai": { "civitai": {
@@ -1296,12 +1179,18 @@
"recipeAdded": "配方已附加到工作流", "recipeAdded": "配方已附加到工作流",
"recipeReplaced": "配方已取代於工作流", "recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗", "recipeFailedToSend": "傳送配方到工作流失敗",
"vaeUpdated": "[TODO: Translate] VAE updated in workflow",
"vaeFailed": "[TODO: Translate] Failed to update VAE in workflow",
"upscalerUpdated": "[TODO: Translate] Upscaler updated in workflow",
"upscalerFailed": "[TODO: Translate] Failed to update upscaler in workflow",
"noMatchingNodes": "目前工作流程中沒有相容的節點", "noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點" "noTargetNodeSelected": "未選擇目標節點"
}, },
"nodeSelector": { "nodeSelector": {
"recipe": "配方", "recipe": "配方",
"lora": "LoRA", "lora": "LoRA",
"vae": "[TODO: Translate] VAE",
"upscaler": "[TODO: Translate] Upscaler",
"replace": "取代", "replace": "取代",
"append": "附加", "append": "附加",
"selectTargetNode": "選擇目標節點", "selectTargetNode": "選擇目標節點",
@@ -1418,14 +1307,7 @@
"showWechatQR": "顯示微信二維碼", "showWechatQR": "顯示微信二維碼",
"hideWechatQR": "隱藏微信二維碼" "hideWechatQR": "隱藏微信二維碼"
}, },
"footer": "感謝您使用 LoRA 管理器!❤️", "footer": "感謝您使用 LoRA 管理器!❤️"
"supporters": {
"title": "感謝所有支持者",
"subtitle": "感謝 {count} 位支持者讓這個專案成為可能",
"specialThanks": "特別感謝",
"allSupporters": "所有支持者",
"totalCount": "共 {count} 位支持者"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1459,8 +1341,6 @@
"loadFailed": "載入 {modelType} 失敗:{message}", "loadFailed": "載入 {modelType} 失敗:{message}",
"refreshComplete": "刷新完成", "refreshComplete": "刷新完成",
"refreshFailed": "刷新配方失敗:{message}", "refreshFailed": "刷新配方失敗:{message}",
"syncComplete": "同步完成",
"syncFailed": "同步配方失敗:{message}",
"updateFailed": "更新配方失敗:{error}", "updateFailed": "更新配方失敗:{error}",
"updateError": "更新配方錯誤:{message}", "updateError": "更新配方錯誤:{message}",
"nameSaved": "配方「{name}」已成功儲存", "nameSaved": "配方「{name}」已成功儲存",
@@ -1497,14 +1377,7 @@
"recipeSaveFailed": "儲存配方失敗:{error}", "recipeSaveFailed": "儲存配方失敗:{error}",
"importFailed": "匯入失敗:{message}", "importFailed": "匯入失敗:{message}",
"folderTreeFailed": "載入資料夾樹狀結構失敗", "folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤", "folderTreeError": "載入資料夾樹狀結構錯誤"
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "未選擇模型", "noModelsSelected": "未選擇模型",
@@ -1524,11 +1397,6 @@
"bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型", "bulkBaseModelUpdateSuccess": "已成功為 {count} 個模型更新基礎模型",
"bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗", "bulkBaseModelUpdatePartial": "已更新 {success} 個模型,{failed} 個模型失敗",
"bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗", "bulkBaseModelUpdateFailed": "更新所選模型的基礎模型失敗",
"skipMetadataRefreshUpdating": "正在更新 {count} 個模型的元數據更新標記...",
"skipMetadataRefreshSet": "已為 {count} 個模型跳過元數據更新",
"skipMetadataRefreshCleared": "已為 {count} 個模型恢復元數據更新",
"skipMetadataRefreshPartial": "已更新 {success} 個模型,{failed} 個失敗",
"skipMetadataRefreshFailed": "無法更新所選模型的元數據更新標記",
"bulkContentRatingUpdating": "正在為 {count} 個模型更新內容分級...", "bulkContentRatingUpdating": "正在為 {count} 個模型更新內容分級...",
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}", "bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗", "bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗",
@@ -1616,7 +1484,6 @@
"folderTreeFailed": "載入資料夾樹狀結構失敗", "folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤", "folderTreeError": "載入資料夾樹狀結構錯誤",
"imagesImported": "範例圖片匯入成功", "imagesImported": "範例圖片匯入成功",
"imagesPartial": "成功匯入 {success} 張圖片,{failed} 張失敗",
"importFailed": "匯入範例圖片失敗:{message}" "importFailed": "匯入範例圖片失敗:{message}"
}, },
"triggerWords": { "triggerWords": {
@@ -1727,20 +1594,6 @@
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.", "content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi", "supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
"title": "檢測到快取損壞"
},
"degraded": {
"title": "檢測到快取問題"
},
"content": "{total} 個快取項目中有 {invalid} 個無效({rate})。這可能會導致模型遺失或錯誤。建議重建快取。",
"rebuildCache": "重建快取",
"dismiss": "關閉",
"rebuilding": "重建快取中...",
"rebuildFailed": "重建快取失敗:{error}",
"retry": "重試"
} }
} }
} }

3
package-lock.json generated
View File

@@ -114,6 +114,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -137,6 +138,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1611,6 +1613,7 @@
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.0.1", "cssstyle": "^4.0.1",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",

View File

@@ -4,9 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "npm run test:js && npm run test:vue", "test": "vitest run",
"test:js": "vitest run",
"test:vue": "cd vue-widgets && npx vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "node scripts/run_frontend_coverage.js" "test:coverage": "node scripts/run_frontend_coverage.js"
}, },

View File

@@ -10,23 +10,16 @@ import urllib.parse
import time import time
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
from .utils.settings_paths import ( from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template
ensure_settings_file,
get_settings_dir,
load_settings_template,
)
# Use an environment variable to control standalone mode # Use an environment variable to control standalone mode
standalone_mode = ( standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _normalize_folder_paths_for_comparison( def _normalize_folder_paths_for_comparison(
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]]
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
"""Normalize folder paths for comparison across libraries.""" """Normalize folder paths for comparison across libraries."""
@@ -56,7 +49,7 @@ def _normalize_folder_paths_for_comparison(
def _normalize_library_folder_paths( def _normalize_library_folder_paths(
library_payload: Mapping[str, Any], library_payload: Mapping[str, Any]
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
"""Return normalized folder paths extracted from a library payload.""" """Return normalized folder paths extracted from a library payload."""
@@ -83,15 +76,9 @@ class Config:
"""Global configuration for LoRA Manager""" """Global configuration for LoRA Manager"""
def __init__(self): def __init__(self):
self.templates_path = os.path.join( self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates')
os.path.dirname(os.path.dirname(__file__)), "templates" self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static')
) self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales')
self.static_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "static"
)
self.i18n_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "locales"
)
# Path mapping dictionary, target to link mapping # Path mapping dictionary, target to link mapping
self._path_mappings: Dict[str, str] = {} self._path_mappings: Dict[str, str] = {}
# Normalized preview root directories used to validate preview access # Normalized preview root directories used to validate preview access
@@ -102,13 +89,11 @@ class Config:
self.checkpoints_roots = None self.checkpoints_roots = None
self.unet_roots = None self.unet_roots = None
self.embeddings_roots = None self.embeddings_roots = None
self.vae_roots = None
self.upscaler_roots = None
self.base_models_roots = self._init_checkpoint_paths() self.base_models_roots = self._init_checkpoint_paths()
self.embeddings_roots = self._init_embedding_paths() self.embeddings_roots = self._init_embedding_paths()
# Extra paths (only for LoRA Manager, not shared with ComfyUI) self.misc_roots = self._init_misc_paths()
self.extra_loras_roots: List[str] = []
self.extra_checkpoints_roots: List[str] = []
self.extra_unet_roots: List[str] = []
self.extra_embeddings_roots: List[str] = []
# Scan symbolic links during initialization # Scan symbolic links during initialization
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
@@ -165,21 +150,19 @@ class Config:
default_library = libraries.get("default", {}) default_library = libraries.get("default", {})
target_folder_paths = { target_folder_paths = {
"loras": list(self.loras_roots), 'loras': list(self.loras_roots),
"checkpoints": list(self.checkpoints_roots or []), 'checkpoints': list(self.checkpoints_roots or []),
"unet": list(self.unet_roots or []), 'unet': list(self.unet_roots or []),
"embeddings": list(self.embeddings_roots or []), 'embeddings': list(self.embeddings_roots or []),
'vae': list(self.vae_roots or []),
'upscale_models': list(self.upscaler_roots or []),
} }
normalized_target_paths = _normalize_folder_paths_for_comparison( normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
target_folder_paths
)
normalized_default_paths: Optional[Dict[str, Set[str]]] = None normalized_default_paths: Optional[Dict[str, Set[str]]] = None
if isinstance(default_library, Mapping): if isinstance(default_library, Mapping):
normalized_default_paths = _normalize_library_folder_paths( normalized_default_paths = _normalize_library_folder_paths(default_library)
default_library
)
if ( if (
not comfy_library not comfy_library
@@ -202,19 +185,13 @@ class Config:
default_lora_root = self.loras_roots[0] default_lora_root = self.loras_roots[0]
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "") default_checkpoint_root = comfy_library.get("default_checkpoint_root", "")
if ( if (not default_checkpoint_root and self.checkpoints_roots and
not default_checkpoint_root len(self.checkpoints_roots) == 1):
and self.checkpoints_roots
and len(self.checkpoints_roots) == 1
):
default_checkpoint_root = self.checkpoints_roots[0] default_checkpoint_root = self.checkpoints_roots[0]
default_embedding_root = comfy_library.get("default_embedding_root", "") default_embedding_root = comfy_library.get("default_embedding_root", "")
if ( if (not default_embedding_root and self.embeddings_roots and
not default_embedding_root len(self.embeddings_roots) == 1):
and self.embeddings_roots
and len(self.embeddings_roots) == 1
):
default_embedding_root = self.embeddings_roots[0] default_embedding_root = self.embeddings_roots[0]
metadata = dict(comfy_library.get("metadata", {})) metadata = dict(comfy_library.get("metadata", {}))
@@ -239,12 +216,11 @@ class Config:
try: try:
if os.path.islink(path): if os.path.islink(path):
return True return True
if platform.system() == "Windows": if platform.system() == 'Windows':
try: try:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # type: ignore[attr-defined] attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception as e: except Exception as e:
logger.error(f"Error checking Windows reparse point: {e}") logger.error(f"Error checking Windows reparse point: {e}")
@@ -257,19 +233,18 @@ class Config:
"""Check if a directory entry is a symlink, including Windows junctions.""" """Check if a directory entry is a symlink, including Windows junctions."""
if entry.is_symlink(): if entry.is_symlink():
return True return True
if platform.system() == "Windows": if platform.system() == 'Windows':
try: try:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) # type: ignore[attr-defined] attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path)
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception: except Exception:
pass pass
return False return False
def _normalize_path(self, path: str) -> str: def _normalize_path(self, path: str) -> str:
return os.path.normpath(path).replace(os.sep, "/") return os.path.normpath(path).replace(os.sep, '/')
def _get_symlink_cache_path(self) -> Path: def _get_symlink_cache_path(self) -> Path:
canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True) canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
@@ -280,11 +255,7 @@ class Config:
roots.extend(self.loras_roots or []) roots.extend(self.loras_roots or [])
roots.extend(self.base_models_roots or []) roots.extend(self.base_models_roots or [])
roots.extend(self.embeddings_roots or []) roots.extend(self.embeddings_roots or [])
# Include extra paths for scanning symlinks roots.extend(self.misc_roots or [])
roots.extend(self.extra_loras_roots or [])
roots.extend(self.extra_checkpoints_roots or [])
roots.extend(self.extra_unet_roots or [])
roots.extend(self.extra_embeddings_roots or [])
return roots return roots
def _build_symlink_fingerprint(self) -> Dict[str, object]: def _build_symlink_fingerprint(self) -> Dict[str, object]:
@@ -303,18 +274,19 @@ class Config:
if self._entry_is_symlink(entry): if self._entry_is_symlink(entry):
try: try:
target = os.path.realpath(entry.path) target = os.path.realpath(entry.path)
direct_symlinks.append( direct_symlinks.append([
[
self._normalize_path(entry.path), self._normalize_path(entry.path),
self._normalize_path(target), self._normalize_path(target)
] ])
)
except OSError: except OSError:
pass pass
except (OSError, PermissionError): except (OSError, PermissionError):
pass pass
return {"roots": unique_roots, "direct_symlinks": sorted(direct_symlinks)} return {
"roots": unique_roots,
"direct_symlinks": sorted(direct_symlinks)
}
def _initialize_symlink_mappings(self) -> None: def _initialize_symlink_mappings(self) -> None:
start = time.perf_counter() start = time.perf_counter()
@@ -331,14 +303,10 @@ class Config:
cached_fingerprint = self._cached_fingerprint cached_fingerprint = self._cached_fingerprint
# Check 1: First-level symlinks unchanged (catches new symlinks at root) # Check 1: First-level symlinks unchanged (catches new symlinks at root)
fingerprint_valid = ( fingerprint_valid = cached_fingerprint and current_fingerprint == cached_fingerprint
cached_fingerprint and current_fingerprint == cached_fingerprint
)
# Check 2: All cached mappings still valid (catches changes at any depth) # Check 2: All cached mappings still valid (catches changes at any depth)
mappings_valid = ( mappings_valid = self._validate_cached_mappings() if fingerprint_valid else False
self._validate_cached_mappings() if fingerprint_valid else False
)
if fingerprint_valid and mappings_valid: if fingerprint_valid and mappings_valid:
return return
@@ -398,9 +366,7 @@ class Config:
for target, link in cached_mappings.items(): for target, link in cached_mappings.items():
if not isinstance(target, str) or not isinstance(link, str): if not isinstance(target, str) or not isinstance(link, str):
continue continue
normalized_mappings[self._normalize_path(target)] = self._normalize_path( normalized_mappings[self._normalize_path(target)] = self._normalize_path(link)
link
)
self._path_mappings = normalized_mappings self._path_mappings = normalized_mappings
@@ -421,9 +387,7 @@ class Config:
parent_dir = loaded_path.parent parent_dir = loaded_path.parent
if parent_dir.name == "cache" and not any(parent_dir.iterdir()): if parent_dir.name == "cache" and not any(parent_dir.iterdir()):
parent_dir.rmdir() parent_dir.rmdir()
logger.info( logger.info("Removed empty legacy cache directory: %s", parent_dir)
"Removed empty legacy cache directory: %s", parent_dir
)
except Exception: except Exception:
pass pass
@@ -434,9 +398,7 @@ class Config:
exc, exc,
) )
else: else:
logger.info( logger.info("Symlink cache loaded with %d mappings", len(self._path_mappings))
"Symlink cache loaded with %d mappings", len(self._path_mappings)
)
return True return True
@@ -448,7 +410,7 @@ class Config:
""" """
for target, link in self._path_mappings.items(): for target, link in self._path_mappings.items():
# Convert normalized paths back to OS paths # Convert normalized paths back to OS paths
link_path = link.replace("/", os.sep) link_path = link.replace('/', os.sep)
# Check if symlink still exists # Check if symlink still exists
if not self._is_link(link_path): if not self._is_link(link_path):
@@ -461,9 +423,7 @@ class Config:
if actual_target != target: if actual_target != target:
logger.debug( logger.debug(
"Symlink target changed: %s -> %s (cached: %s)", "Symlink target changed: %s -> %s (cached: %s)",
link_path, link_path, actual_target, target
actual_target,
target,
) )
return False return False
except OSError: except OSError:
@@ -482,64 +442,89 @@ class Config:
try: try:
with cache_path.open("w", encoding="utf-8") as handle: with cache_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2) json.dump(payload, handle, ensure_ascii=False, indent=2)
logger.debug( logger.debug("Symlink cache saved to %s with %d mappings", cache_path, len(self._path_mappings))
"Symlink cache saved to %s with %d mappings",
cache_path,
len(self._path_mappings),
)
except Exception as exc: except Exception as exc:
logger.info("Failed to write symlink cache %s: %s", cache_path, exc) logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
def _scan_symbolic_links(self): def _scan_symbolic_links(self):
"""Scan symbolic links in LoRA, Checkpoint, and Embedding root directories. """Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
Only scans the first level of each root directory to avoid performance
issues with large file systems. Detects symlinks and Windows junctions
at the root level only (not nested symlinks in subdirectories).
"""
start = time.perf_counter() start = time.perf_counter()
# Reset mappings before rescanning to avoid stale entries # Reset mappings before rescanning to avoid stale entries
self._path_mappings.clear() self._path_mappings.clear()
self._seed_root_symlink_mappings() self._seed_root_symlink_mappings()
visited_dirs: Set[str] = set()
for root in self._symlink_roots(): for root in self._symlink_roots():
self._scan_first_level_symlinks(root) self._scan_directory_links(root, visited_dirs)
logger.debug( logger.debug(
"Symlink scan finished in %.2f ms with %d mappings", "Symlink scan finished in %.2f ms with %d mappings",
(time.perf_counter() - start) * 1000, (time.perf_counter() - start) * 1000,
len(self._path_mappings), len(self._path_mappings),
) )
def _scan_first_level_symlinks(self, root: str): def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
"""Scan only the first level of a directory for symlinks. """Iteratively scan directory symlinks to avoid deep recursion."""
This avoids traversing the entire directory tree which can be extremely
slow for large model collections. Only symlinks directly under the root
are detected.
"""
try: try:
with os.scandir(root) as it: # Note: We only use realpath for the initial root if it's not already resolved
# to ensure we have a valid entry point.
root_real = self._normalize_path(os.path.realpath(root))
except OSError:
root_real = self._normalize_path(root)
if root_real in visited_dirs:
return
visited_dirs.add(root_real)
# Stack entries: (display_path, real_resolved_path)
stack: List[Tuple[str, str]] = [(root, root_real)]
while stack:
current_display, current_real = stack.pop()
try:
with os.scandir(current_display) as it:
for entry in it: for entry in it:
try: try:
# Only detect symlinks including Windows junctions # 1. Detect symlinks including Windows junctions
# Skip normal directories to avoid deep traversal is_link = self._entry_is_symlink(entry)
if not self._entry_is_symlink(entry):
continue
# Resolve the symlink target if is_link:
# Only resolve realpath when we actually find a link
target_path = os.path.realpath(entry.path) target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path): if not os.path.isdir(target_path):
continue continue
normalized_target = self._normalize_path(target_path)
self.add_path_mapping(entry.path, target_path) self.add_path_mapping(entry.path, target_path)
if normalized_target in visited_dirs:
continue
visited_dirs.add(normalized_target)
stack.append((target_path, normalized_target))
continue
# 2. Process normal directories
if not entry.is_dir(follow_symlinks=False):
continue
# For normal directories, we avoid realpath() call by
# incrementally building the real path relative to current_real.
# This is safe because 'entry' is NOT a symlink.
entry_real = self._normalize_path(os.path.join(current_real, entry.name))
if entry_real in visited_dirs:
continue
visited_dirs.add(entry_real)
stack.append((entry.path, entry_real))
except Exception as inner_exc: except Exception as inner_exc:
logger.debug( logger.debug(
"Error processing directory entry %s: %s", "Error processing directory entry %s: %s", entry.path, inner_exc
entry.path,
inner_exc,
) )
except Exception as e: except Exception as e:
logger.error(f"Error scanning links in {root}: {e}") logger.error(f"Error scanning links in {current_display}: {e}")
def add_path_mapping(self, link_path: str, target_path: str): def add_path_mapping(self, link_path: str, target_path: str):
"""Add a symbolic link path mapping """Add a symbolic link path mapping
@@ -620,45 +605,34 @@ class Config:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
for root in self.embeddings_roots or []: for root in self.embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
# Include extra paths for preview access for root in self.misc_roots or []:
for root in self.extra_loras_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_checkpoints_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_unet_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.extra_embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root)) preview_roots.update(self._expand_preview_root(root))
for target, link in self._path_mappings.items(): for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target)) preview_roots.update(self._expand_preview_root(target))
preview_roots.update(self._expand_preview_root(link)) preview_roots.update(self._expand_preview_root(link))
self._preview_root_paths = { self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
path for path in preview_roots if path.is_absolute()
}
logger.debug( logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings", "Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d misc roots, %d symlink mappings",
len(self._preview_root_paths), len(self._preview_root_paths),
len(self.loras_roots or []), len(self.loras_roots or []),
len(self.extra_loras_roots or []),
len(self.base_models_roots or []), len(self.base_models_roots or []),
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []), len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []), len(self.misc_roots or []),
len(self._path_mappings), len(self._path_mappings),
) )
def map_path_to_link(self, path: str) -> str: def map_path_to_link(self, path: str) -> str:
"""Map a target path back to its symbolic link path""" """Map a target path back to its symbolic link path"""
normalized_path = os.path.normpath(path).replace(os.sep, "/") normalized_path = os.path.normpath(path).replace(os.sep, '/')
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path in self._path_mappings.items(): for target_path, link_path in self._path_mappings.items():
# Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc) # Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc)
if normalized_path == target_path: if normalized_path == target_path:
return link_path return link_path
if normalized_path.startswith(target_path + "/"): if normalized_path.startswith(target_path + '/'):
# If the path starts with the target path, replace with link path # If the path starts with the target path, replace with link path
mapped_path = normalized_path.replace(target_path, link_path, 1) mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path return mapped_path
@@ -666,14 +640,14 @@ class Config:
def map_link_to_path(self, link_path: str) -> str: def map_link_to_path(self, link_path: str) -> str:
"""Map a symbolic link path back to the actual path""" """Map a symbolic link path back to the actual path"""
normalized_link = os.path.normpath(link_path).replace(os.sep, "/") normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path_mapped in self._path_mappings.items(): for target_path, link_path_mapped in self._path_mappings.items():
# Match whole path components # Match whole path components
if normalized_link == link_path_mapped: if normalized_link == link_path_mapped:
return target_path return target_path
if normalized_link.startswith(link_path_mapped + "/"): if normalized_link.startswith(link_path_mapped + '/'):
# If the path starts with the link path, replace with actual path # If the path starts with the link path, replace with actual path
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1) mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
return mapped_path return mapped_path
@@ -686,8 +660,8 @@ class Config:
continue continue
if not os.path.exists(path): if not os.path.exists(path):
continue continue
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, "/") real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
normalized = os.path.normpath(path).replace(os.sep, "/") normalized = os.path.normpath(path).replace(os.sep, '/')
if real_path not in dedup: if real_path not in dedup:
dedup[real_path] = normalized dedup[real_path] = normalized
return dedup return dedup
@@ -697,9 +671,7 @@ class Config:
unique_paths = sorted(path_map.values(), key=lambda p: p.lower()) unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace( real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
@@ -707,33 +679,10 @@ class Config:
def _prepare_checkpoint_paths( def _prepare_checkpoint_paths(
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str] self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
) -> Tuple[List[str], List[str], List[str]]: ) -> List[str]:
"""Prepare checkpoint paths and return (all_roots, checkpoint_roots, unet_roots).
Returns:
Tuple of (all_unique_paths, checkpoint_only_paths, unet_only_paths)
This method does NOT modify instance variables - callers must set them.
"""
checkpoint_map = self._dedupe_existing_paths(checkpoint_paths) checkpoint_map = self._dedupe_existing_paths(checkpoint_paths)
unet_map = self._dedupe_existing_paths(unet_paths) unet_map = self._dedupe_existing_paths(unet_paths)
# Detect when checkpoints and unet share the same physical location
# This is a configuration issue that can cause duplicate model entries
overlapping_real_paths = set(checkpoint_map.keys()) & set(unet_map.keys())
if overlapping_real_paths:
logger.warning(
"Detected overlapping paths between 'checkpoints' and 'diffusion_models' (unet). "
"They should not point to the same physical folder as they are different model types. "
"Please fix your ComfyUI path configuration to separate these folders. "
"Falling back to 'checkpoints' for backward compatibility. "
"Overlapping real paths: %s",
[checkpoint_map.get(rp, rp) for rp in overlapping_real_paths],
)
# Remove overlapping paths from unet_map to prioritize checkpoints
for rp in overlapping_real_paths:
if rp in unet_map:
del unet_map[rp]
merged_map: Dict[str, str] = {} merged_map: Dict[str, str] = {}
for real_path, original in {**checkpoint_map, **unet_map}.items(): for real_path, original in {**checkpoint_map, **unet_map}.items():
if real_path not in merged_map: if real_path not in merged_map:
@@ -743,95 +692,40 @@ class Config:
checkpoint_values = set(checkpoint_map.values()) checkpoint_values = set(checkpoint_map.values())
unet_values = set(unet_map.values()) unet_values = set(unet_map.values())
checkpoint_roots = [p for p in unique_paths if p in checkpoint_values] self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values]
unet_roots = [p for p in unique_paths if p in unet_values] self.unet_roots = [p for p in unique_paths if p in unet_values]
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace( real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
return unique_paths, checkpoint_roots, unet_roots return unique_paths
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]: def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
path_map = self._dedupe_existing_paths(raw_paths) path_map = self._dedupe_existing_paths(raw_paths)
unique_paths = sorted(path_map.values(), key=lambda p: p.lower()) unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace( real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
return unique_paths return unique_paths
def _apply_library_paths( def _apply_library_paths(self, folder_paths: Mapping[str, Iterable[str]]) -> None:
self,
folder_paths: Mapping[str, Iterable[str]],
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
) -> None:
self._path_mappings.clear() self._path_mappings.clear()
self._preview_root_paths = set() self._preview_root_paths = set()
lora_paths = folder_paths.get("loras", []) or [] lora_paths = folder_paths.get('loras', []) or []
checkpoint_paths = folder_paths.get("checkpoints", []) or [] checkpoint_paths = folder_paths.get('checkpoints', []) or []
unet_paths = folder_paths.get("unet", []) or [] unet_paths = folder_paths.get('unet', []) or []
embedding_paths = folder_paths.get("embeddings", []) or [] embedding_paths = folder_paths.get('embeddings', []) or []
self.loras_roots = self._prepare_lora_paths(lora_paths) self.loras_roots = self._prepare_lora_paths(lora_paths)
( self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.base_models_roots,
self.checkpoints_roots,
self.unet_roots,
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths) self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
extra_paths = extra_folder_paths or {}
extra_lora_paths = extra_paths.get("loras", []) or []
extra_checkpoint_paths = extra_paths.get("checkpoints", []) or []
extra_unet_paths = extra_paths.get("unet", []) or []
extra_embedding_paths = extra_paths.get("embeddings", []) or []
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
(
_,
self.extra_checkpoints_roots,
self.extra_unet_roots,
) = self._prepare_checkpoint_paths(extra_checkpoint_paths, extra_unet_paths)
self.extra_embeddings_roots = self._prepare_embedding_paths(
extra_embedding_paths
)
# Log extra folder paths
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"
+ "\n - "
+ "\n - ".join(self.extra_loras_roots)
)
if self.extra_checkpoints_roots:
logger.info(
"Found extra checkpoint roots:"
+ "\n - "
+ "\n - ".join(self.extra_checkpoints_roots)
)
if self.extra_unet_roots:
logger.info(
"Found extra diffusion model roots:"
+ "\n - "
+ "\n - ".join(self.extra_unet_roots)
)
if self.extra_embeddings_roots:
logger.info(
"Found extra embedding roots:"
+ "\n - "
+ "\n - ".join(self.extra_embeddings_roots)
)
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
def _init_lora_paths(self) -> List[str]: def _init_lora_paths(self) -> List[str]:
@@ -839,10 +733,7 @@ class Config:
try: try:
raw_paths = folder_paths.get_folder_paths("loras") raw_paths = folder_paths.get_folder_paths("loras")
unique_paths = self._prepare_lora_paths(raw_paths) unique_paths = self._prepare_lora_paths(raw_paths)
logger.info( logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
"Found LoRA roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning("No valid loras folders found in ComfyUI configuration") logger.warning("No valid loras folders found in ComfyUI configuration")
@@ -858,21 +749,12 @@ class Config:
try: try:
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints") raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet") raw_unet_paths = folder_paths.get_folder_paths("unet")
( unique_paths = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
unique_paths,
self.checkpoints_roots,
self.unet_roots,
) = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths)
logger.info( logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
"Found checkpoint roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning( logger.warning("No valid checkpoint folders found in ComfyUI configuration")
"No valid checkpoint folders found in ComfyUI configuration"
)
return [] return []
return unique_paths return unique_paths
@@ -885,15 +767,10 @@ class Config:
try: try:
raw_paths = folder_paths.get_folder_paths("embeddings") raw_paths = folder_paths.get_folder_paths("embeddings")
unique_paths = self._prepare_embedding_paths(raw_paths) unique_paths = self._prepare_embedding_paths(raw_paths)
logger.info( logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
"Found embedding roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning( logger.warning("No valid embeddings folders found in ComfyUI configuration")
"No valid embeddings folders found in ComfyUI configuration"
)
return [] return []
return unique_paths return unique_paths
@@ -901,32 +778,59 @@ class Config:
logger.warning(f"Error initializing embedding paths: {e}") logger.warning(f"Error initializing embedding paths: {e}")
return [] return []
def _init_misc_paths(self) -> List[str]:
"""Initialize and validate misc (VAE and upscaler) paths from ComfyUI settings"""
try:
raw_vae_paths = folder_paths.get_folder_paths("vae")
raw_upscaler_paths = folder_paths.get_folder_paths("upscale_models")
unique_paths = self._prepare_misc_paths(raw_vae_paths, raw_upscaler_paths)
logger.info("Found misc roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
if not unique_paths:
logger.warning("No valid VAE or upscaler folders found in ComfyUI configuration")
return []
return unique_paths
except Exception as e:
logger.warning(f"Error initializing misc paths: {e}")
return []
def _prepare_misc_paths(
self, vae_paths: Iterable[str], upscaler_paths: Iterable[str]
) -> List[str]:
vae_map = self._dedupe_existing_paths(vae_paths)
upscaler_map = self._dedupe_existing_paths(upscaler_paths)
merged_map: Dict[str, str] = {}
for real_path, original in {**vae_map, **upscaler_map}.items():
if real_path not in merged_map:
merged_map[real_path] = original
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
vae_values = set(vae_map.values())
upscaler_values = set(upscaler_map.values())
self.vae_roots = [p for p in unique_paths if p in vae_values]
self.upscaler_roots = [p for p in unique_paths if p in upscaler_values]
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
def get_preview_static_url(self, preview_path: str) -> str: def get_preview_static_url(self, preview_path: str) -> str:
if not preview_path: if not preview_path:
return "" return ""
normalized = os.path.normpath(preview_path).replace(os.sep, "/") normalized = os.path.normpath(preview_path).replace(os.sep, '/')
encoded_path = urllib.parse.quote(normalized, safe="") encoded_path = urllib.parse.quote(normalized, safe='')
return f"/api/lm/previews?path={encoded_path}" return f'/api/lm/previews?path={encoded_path}'
def is_preview_path_allowed(self, preview_path: str) -> bool: def is_preview_path_allowed(self, preview_path: str) -> bool:
"""Return ``True`` if ``preview_path`` is within an allowed directory. """Return ``True`` if ``preview_path`` is within an allowed directory."""
If the path is initially rejected, attempts to discover deep symlinks
that were not scanned during initialization. If a symlink is found,
updates the in-memory path mappings and retries the check.
"""
if self._is_path_in_allowed_roots(preview_path):
return True
if self._try_discover_deep_symlink(preview_path):
return self._is_path_in_allowed_roots(preview_path)
return False
def _is_path_in_allowed_roots(self, preview_path: str) -> bool:
"""Check if preview_path is within allowed preview roots without modification."""
if not preview_path: if not preview_path:
return False return False
@@ -936,106 +840,45 @@ class Config:
except Exception: except Exception:
return False return False
# Use os.path.normcase for case-insensitive comparison on Windows.
# On Windows, Path.relative_to() is case-sensitive for drive letters,
# causing paths like 'a:/folder' to not match 'A:/folder'.
candidate_str = os.path.normcase(str(candidate)) candidate_str = os.path.normcase(str(candidate))
for root in self._preview_root_paths: for root in self._preview_root_paths:
root_str = os.path.normcase(str(root)) root_str = os.path.normcase(str(root))
# Check if candidate is equal to or under the root directory
if candidate_str == root_str or candidate_str.startswith(root_str + os.sep): if candidate_str == root_str or candidate_str.startswith(root_str + os.sep):
return True return True
if self._preview_root_paths:
logger.debug( logger.debug(
"Path not in allowed roots: %s (candidate=%s, num_roots=%d)", "Preview path rejected: %s (candidate=%s, num_roots=%d, first_root=%s)",
preview_path, preview_path,
candidate_str, candidate_str,
len(self._preview_root_paths), len(self._preview_root_paths),
os.path.normcase(str(next(iter(self._preview_root_paths)))),
) )
else:
return False
def _try_discover_deep_symlink(self, preview_path: str) -> bool:
"""Attempt to discover a deep symlink that contains the preview_path.
Walks up from the preview path to the root directories, checking each
parent directory for symlinks. If a symlink is found, updates the
in-memory path mappings and preview roots.
Only updates in-memory state (self._path_mappings and self._preview_root_paths),
does not modify the persistent cache file.
Returns:
True if a symlink was discovered and mappings updated, False otherwise.
"""
if not preview_path:
return False
try:
candidate = Path(preview_path).expanduser()
except Exception:
return False
current = candidate
while True:
try:
if self._is_link(str(current)):
try:
target = os.path.realpath(str(current))
normalized_target = self._normalize_path(target)
normalized_link = self._normalize_path(str(current))
self._path_mappings[normalized_target] = normalized_link
self._preview_root_paths.update(
self._expand_preview_root(normalized_target)
)
self._preview_root_paths.update(
self._expand_preview_root(normalized_link)
)
logger.debug( logger.debug(
"Discovered deep symlink: %s -> %s (preview path: %s)", "Preview path rejected (no roots configured): %s",
normalized_link,
normalized_target,
preview_path, preview_path,
) )
return True
except OSError:
pass
except OSError:
pass
parent = current.parent
if parent == current:
break
current = parent
return False return False
def apply_library_settings(self, library_config: Mapping[str, object]) -> None: def apply_library_settings(self, library_config: Mapping[str, object]) -> None:
"""Update runtime paths to match the provided library configuration.""" """Update runtime paths to match the provided library configuration."""
folder_paths = ( folder_paths = library_config.get('folder_paths') if isinstance(library_config, Mapping) else {}
library_config.get("folder_paths")
if isinstance(library_config, Mapping)
else {}
)
extra_folder_paths = (
library_config.get("extra_folder_paths")
if isinstance(library_config, Mapping)
else None
)
if not isinstance(folder_paths, Mapping): if not isinstance(folder_paths, Mapping):
folder_paths = {} folder_paths = {}
if not isinstance(extra_folder_paths, Mapping):
extra_folder_paths = None
self._apply_library_paths(folder_paths, extra_folder_paths) self._apply_library_paths(folder_paths)
logger.info( logger.info(
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)", "Applied library settings with %d lora roots, %d checkpoint roots, and %d embedding roots",
len(self.loras_roots or []), len(self.loras_roots or []),
len(self.extra_loras_roots or []),
len(self.base_models_roots or []), len(self.base_models_roots or []),
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []), len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []),
) )
def get_library_registry_snapshot(self) -> Dict[str, object]: def get_library_registry_snapshot(self) -> Dict[str, object]:
@@ -1055,6 +898,5 @@ class Config:
logger.debug("Failed to collect library registry snapshot: %s", exc) logger.debug("Failed to collect library registry snapshot: %s", exc)
return {"active_library": "", "libraries": {}} return {"active_library": "", "libraries": {}}
# Global config instance # Global config instance
config = Config() config = Config()

View File

@@ -5,10 +5,7 @@ import logging
from .utils.logging_config import setup_logging from .utils.logging_config import setup_logging
# Check if we're in standalone mode # Check if we're in standalone mode
standalone_mode = ( standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
# Only setup logging prefix if not in standalone mode # Only setup logging prefix if not in standalone mode
if not standalone_mode: if not standalone_mode:
@@ -17,10 +14,7 @@ if not standalone_mode:
from server import PromptServer # type: ignore from server import PromptServer # type: ignore
from .config import config from .config import config
from .services.model_service_factory import ( from .services.model_service_factory import ModelServiceFactory, register_default_model_types
ModelServiceFactory,
register_default_model_types,
)
from .routes.recipe_routes import RecipeRoutes from .routes.recipe_routes import RecipeRoutes
from .routes.stats_routes import StatsRoutes from .routes.stats_routes import StatsRoutes
from .routes.update_routes import UpdateRoutes from .routes.update_routes import UpdateRoutes
@@ -67,7 +61,6 @@ class _SettingsProxy:
settings = _SettingsProxy() settings = _SettingsProxy()
class LoraManager: class LoraManager:
"""Main entry point for LoRA Manager plugin""" """Main entry point for LoRA Manager plugin"""
@@ -83,8 +76,7 @@ class LoraManager:
( (
idx idx
for idx, middleware in enumerate(app.middlewares) for idx, middleware in enumerate(app.middlewares)
if getattr(middleware, "__name__", "") if getattr(middleware, "__name__", "") == "block_external_middleware"
== "block_external_middleware"
), ),
None, None,
) )
@@ -92,9 +84,7 @@ class LoraManager:
if block_middleware_index is None: if block_middleware_index is None:
app.middlewares.append(relax_csp_for_remote_media) app.middlewares.append(relax_csp_for_remote_media)
else: else:
app.middlewares.insert( app.middlewares.insert(block_middleware_index, relax_csp_for_remote_media)
block_middleware_index, relax_csp_for_remote_media
)
# Increase allowed header sizes so browsers with large localhost cookie # Increase allowed header sizes so browsers with large localhost cookie
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default # jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
@@ -115,7 +105,7 @@ class LoraManager:
app._handler_args = updated_handler_args app._handler_args = updated_handler_args
# Configure aiohttp access logger to be less verbose # Configure aiohttp access logger to be less verbose
logging.getLogger("aiohttp.access").setLevel(logging.WARNING) logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
# Add specific suppression for connection reset errors # Add specific suppression for connection reset errors
class ConnectionResetFilter(logging.Filter): class ConnectionResetFilter(logging.Filter):
@@ -134,23 +124,19 @@ class LoraManager:
asyncio_logger.addFilter(ConnectionResetFilter()) asyncio_logger.addFilter(ConnectionResetFilter())
# Add static route for example images if the path exists in settings # Add static route for example images if the path exists in settings
example_images_path = settings.get("example_images_path") example_images_path = settings.get('example_images_path')
logger.info(f"Example images path: {example_images_path}") logger.info(f"Example images path: {example_images_path}")
if example_images_path and os.path.exists(example_images_path): if example_images_path and os.path.exists(example_images_path):
app.router.add_static("/example_images_static", example_images_path) app.router.add_static('/example_images_static', example_images_path)
logger.info( logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
f"Added static route for example images: /example_images_static -> {example_images_path}"
)
# Add static route for locales JSON files # Add static route for locales JSON files
if os.path.exists(config.i18n_path): if os.path.exists(config.i18n_path):
app.router.add_static("/locales", config.i18n_path) app.router.add_static('/locales', config.i18n_path)
logger.info( logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
f"Added static route for locales: /locales -> {config.i18n_path}"
)
# Add static route for plugin assets # Add static route for plugin assets
app.router.add_static("/loras_static", config.static_path) app.router.add_static('/loras_static', config.static_path)
# Register default model types with the factory # Register default model types with the factory
register_default_model_types() register_default_model_types()
@@ -168,11 +154,9 @@ class LoraManager:
PreviewRoutes.setup_routes(app) PreviewRoutes.setup_routes(app)
# Setup WebSocket routes that are shared across all model types # Setup WebSocket routes that are shared across all model types
app.router.add_get("/ws/fetch-progress", ws_manager.handle_connection) app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
app.router.add_get( app.router.add_get('/ws/download-progress', ws_manager.handle_download_connection)
"/ws/download-progress", ws_manager.handle_download_connection app.router.add_get('/ws/init-progress', ws_manager.handle_init_connection)
)
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
# Schedule service initialization # Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services()) app.on_startup.append(lambda app: cls._initialize_services())
@@ -184,39 +168,6 @@ class LoraManager:
async def _initialize_services(cls): async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry""" """Initialize all services using the ServiceRegistry"""
try: try:
# Apply library settings to load extra folder paths before scanning
# Only apply if extra paths haven't been loaded yet (preserves test mocks)
try:
from .services.settings_manager import get_settings_manager
settings_manager = get_settings_manager()
library_name = settings_manager.get_active_library_name()
libraries = settings_manager.get_libraries()
if library_name and library_name in libraries:
library_config = libraries[library_name]
# Only apply settings if extra paths are not already configured
# This preserves values set by tests via monkeypatch
extra_paths = library_config.get("extra_folder_paths", {})
has_extra_paths = (
config.extra_loras_roots
or config.extra_checkpoints_roots
or config.extra_unet_roots
or config.extra_embeddings_roots
)
if not has_extra_paths and any(extra_paths.values()):
config.apply_library_settings(library_config)
logger.info(
"Applied library settings for '%s' with extra paths: loras=%s, checkpoints=%s, embeddings=%s",
library_name,
extra_paths.get("loras", []),
extra_paths.get("checkpoints", []),
extra_paths.get("embeddings", []),
)
except Exception as exc:
logger.warning(
"Failed to apply library settings during initialization: %s", exc
)
# Initialize CivitaiClient first to ensure it's ready for other services # Initialize CivitaiClient first to ensure it's ready for other services
await ServiceRegistry.get_civitai_client() await ServiceRegistry.get_civitai_client()
@@ -224,7 +175,6 @@ class LoraManager:
await ServiceRegistry.get_download_manager() await ServiceRegistry.get_download_manager()
from .services.metadata_service import initialize_metadata_providers from .services.metadata_service import initialize_metadata_providers
await initialize_metadata_providers() await initialize_metadata_providers()
# Initialize WebSocket manager # Initialize WebSocket manager
@@ -234,64 +184,47 @@ class LoraManager:
lora_scanner = await ServiceRegistry.get_lora_scanner() lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner()
misc_scanner = await ServiceRegistry.get_misc_scanner()
# Initialize recipe scanner if needed # Initialize recipe scanner if needed
recipe_scanner = await ServiceRegistry.get_recipe_scanner() recipe_scanner = await ServiceRegistry.get_recipe_scanner()
# Create low-priority initialization tasks # Create low-priority initialization tasks
init_tasks = [ init_tasks = [
asyncio.create_task( asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init'),
lora_scanner.initialize_in_background(), name="lora_cache_init" asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init'),
), asyncio.create_task(embedding_scanner.initialize_in_background(), name='embedding_cache_init'),
asyncio.create_task( asyncio.create_task(misc_scanner.initialize_in_background(), name='misc_cache_init'),
checkpoint_scanner.initialize_in_background(), asyncio.create_task(recipe_scanner.initialize_in_background(), name='recipe_cache_init')
name="checkpoint_cache_init",
),
asyncio.create_task(
embedding_scanner.initialize_in_background(),
name="embedding_cache_init",
),
asyncio.create_task(
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
),
] ]
await ExampleImagesMigration.check_and_run_migrations() await ExampleImagesMigration.check_and_run_migrations()
# Schedule post-initialization tasks to run after scanners complete # Schedule post-initialization tasks to run after scanners complete
asyncio.create_task( asyncio.create_task(
cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks" cls._run_post_initialization_tasks(init_tasks),
name='post_init_tasks'
) )
logger.debug( logger.debug("LoRA Manager: All services initialized and background tasks scheduled")
"LoRA Manager: All services initialized and background tasks scheduled"
)
except Exception as e: except Exception as e:
logger.error( logger.error(f"LoRA Manager: Error initializing services: {e}", exc_info=True)
f"LoRA Manager: Error initializing services: {e}", exc_info=True
)
@classmethod @classmethod
async def _run_post_initialization_tasks(cls, init_tasks): async def _run_post_initialization_tasks(cls, init_tasks):
"""Run post-initialization tasks after all scanners complete""" """Run post-initialization tasks after all scanners complete"""
try: try:
logger.debug( logger.debug("LoRA Manager: Waiting for scanner initialization to complete...")
"LoRA Manager: Waiting for scanner initialization to complete..."
)
# Wait for all scanner initialization tasks to complete # Wait for all scanner initialization tasks to complete
await asyncio.gather(*init_tasks, return_exceptions=True) await asyncio.gather(*init_tasks, return_exceptions=True)
logger.debug( logger.debug("LoRA Manager: Scanner initialization completed, starting post-initialization tasks...")
"LoRA Manager: Scanner initialization completed, starting post-initialization tasks..."
)
# Run post-initialization tasks # Run post-initialization tasks
post_tasks = [ post_tasks = [
asyncio.create_task( asyncio.create_task(cls._cleanup_backup_files(), name='cleanup_bak_files'),
cls._cleanup_backup_files(), name="cleanup_bak_files"
),
# Add more post-initialization tasks here as needed # Add more post-initialization tasks here as needed
# asyncio.create_task(cls._another_post_task(), name='another_task'), # asyncio.create_task(cls._another_post_task(), name='another_task'),
] ]
@@ -303,20 +236,14 @@ class LoraManager:
for i, result in enumerate(results): for i, result in enumerate(results):
task_name = post_tasks[i].get_name() task_name = post_tasks[i].get_name()
if isinstance(result, Exception): if isinstance(result, Exception):
logger.error( logger.error(f"Post-initialization task '{task_name}' failed: {result}")
f"Post-initialization task '{task_name}' failed: {result}"
)
else: else:
logger.debug( logger.debug(f"Post-initialization task '{task_name}' completed successfully")
f"Post-initialization task '{task_name}' completed successfully"
)
logger.debug("LoRA Manager: All post-initialization tasks completed") logger.debug("LoRA Manager: All post-initialization tasks completed")
except Exception as e: except Exception as e:
logger.error( logger.error(f"LoRA Manager: Error in post-initialization tasks: {e}", exc_info=True)
f"LoRA Manager: Error in post-initialization tasks: {e}", exc_info=True
)
@classmethod @classmethod
async def _cleanup_backup_files(cls): async def _cleanup_backup_files(cls):
@@ -327,8 +254,9 @@ class LoraManager:
# Collect all model roots # Collect all model roots
all_roots = set() all_roots = set()
all_roots.update(config.loras_roots) all_roots.update(config.loras_roots)
all_roots.update(config.base_models_roots or []) all_roots.update(config.base_models_roots)
all_roots.update(config.embeddings_roots or []) all_roots.update(config.embeddings_roots)
all_roots.update(config.misc_roots or [])
total_deleted = 0 total_deleted = 0
total_size_freed = 0 total_size_freed = 0
@@ -338,17 +266,12 @@ class LoraManager:
continue continue
try: try:
( deleted_count, size_freed = await cls._cleanup_backup_files_in_directory(root_path)
deleted_count,
size_freed,
) = await cls._cleanup_backup_files_in_directory(root_path)
total_deleted += deleted_count total_deleted += deleted_count
total_size_freed += size_freed total_size_freed += size_freed
if deleted_count > 0: if deleted_count > 0:
logger.debug( logger.debug(f"Cleaned up {deleted_count} .bak files in {root_path} (freed {size_freed / (1024*1024):.2f} MB)")
f"Cleaned up {deleted_count} .bak files in {root_path} (freed {size_freed / (1024 * 1024):.2f} MB)"
)
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up .bak files in {root_path}: {e}") logger.error(f"Error cleaning up .bak files in {root_path}: {e}")
@@ -357,9 +280,7 @@ class LoraManager:
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
if total_deleted > 0: if total_deleted > 0:
logger.debug( logger.debug(f"Backup cleanup completed: removed {total_deleted} .bak files, freed {total_size_freed / (1024*1024):.2f} MB total")
f"Backup cleanup completed: removed {total_deleted} .bak files, freed {total_size_freed / (1024 * 1024):.2f} MB total"
)
else: else:
logger.debug("Backup cleanup completed: no .bak files found") logger.debug("Backup cleanup completed: no .bak files found")
@@ -392,9 +313,7 @@ class LoraManager:
with os.scandir(path) as it: with os.scandir(path) as it:
for entry in it: for entry in it:
try: try:
if entry.is_file( if entry.is_file(follow_symlinks=True) and entry.name.endswith('.bak'):
follow_symlinks=True
) and entry.name.endswith(".bak"):
file_size = entry.stat().st_size file_size = entry.stat().st_size
os.remove(entry.path) os.remove(entry.path)
deleted_count += 1 deleted_count += 1
@@ -405,9 +324,7 @@ class LoraManager:
cleanup_recursive(entry.path) cleanup_recursive(entry.path)
except Exception as e: except Exception as e:
logger.warning( logger.warning(f"Could not delete .bak file {entry.path}: {e}")
f"Could not delete .bak file {entry.path}: {e}"
)
except Exception as e: except Exception as e:
logger.error(f"Error scanning directory {path} for .bak files: {e}") logger.error(f"Error scanning directory {path} for .bak files: {e}")
@@ -425,21 +342,21 @@ class LoraManager:
service = ExampleImagesCleanupService() service = ExampleImagesCleanupService()
result = await service.cleanup_example_image_folders() result = await service.cleanup_example_image_folders()
if result.get("success"): if result.get('success'):
logger.debug( logger.debug(
"Manual example images cleanup completed: moved=%s", "Manual example images cleanup completed: moved=%s",
result.get("moved_total"), result.get('moved_total'),
) )
elif result.get("partial_success"): elif result.get('partial_success'):
logger.warning( logger.warning(
"Manual example images cleanup partially succeeded: moved=%s failures=%s", "Manual example images cleanup partially succeeded: moved=%s failures=%s",
result.get("moved_total"), result.get('moved_total'),
result.get("move_failures"), result.get('move_failures'),
) )
else: else:
logger.debug( logger.debug(
"Manual example images cleanup skipped or failed: %s", "Manual example images cleanup skipped or failed: %s",
result.get("error", "no changes"), result.get('error', 'no changes'),
) )
return result return result
@@ -447,9 +364,9 @@ class LoraManager:
except Exception as e: # pragma: no cover - defensive guard except Exception as e: # pragma: no cover - defensive guard
logger.error(f"Error during example images cleanup: {e}", exc_info=True) logger.error(f"Error during example images cleanup: {e}", exc_info=True)
return { return {
"success": False, 'success': False,
"error": str(e), 'error': str(e),
"error_code": "unexpected_error", 'error_code': 'unexpected_error',
} }
@classmethod @classmethod

View File

@@ -1,13 +1,7 @@
import os import os
import logging
logger = logging.getLogger(__name__)
# Check if running in standalone mode # Check if running in standalone mode
standalone_mode = ( standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
if not standalone_mode: if not standalone_mode:
from .metadata_hook import MetadataHook from .metadata_hook import MetadataHook
@@ -20,17 +14,17 @@ if not standalone_mode:
# Initialize registry # Initialize registry
registry = MetadataRegistry() registry = MetadataRegistry()
logger.info("ComfyUI Metadata Collector initialized") print("ComfyUI Metadata Collector initialized")
def get_metadata(prompt_id=None): # type: ignore[no-redef] def get_metadata(prompt_id=None):
"""Helper function to get metadata from the registry""" """Helper function to get metadata from the registry"""
registry = MetadataRegistry() registry = MetadataRegistry()
return registry.get_metadata(prompt_id) return registry.get_metadata(prompt_id)
else: else:
# Standalone mode - provide dummy implementations # Standalone mode - provide dummy implementations
def init(): def init():
logger.info("ComfyUI Metadata Collector disabled in standalone mode") print("ComfyUI Metadata Collector disabled in standalone mode")
def get_metadata(prompt_id=None): # type: ignore[no-redef] def get_metadata(prompt_id=None):
"""Dummy implementation for standalone mode""" """Dummy implementation for standalone mode"""
return {} return {}

View File

@@ -1,10 +1,7 @@
import sys import sys
import inspect import inspect
import logging
from .metadata_registry import MetadataRegistry from .metadata_registry import MetadataRegistry
logger = logging.getLogger(__name__)
class MetadataHook: class MetadataHook:
"""Install hooks for metadata collection""" """Install hooks for metadata collection"""
@@ -26,7 +23,7 @@ class MetadataHook:
# If we can't find the execution module, we can't install hooks # If we can't find the execution module, we can't install hooks
if execution is None: if execution is None:
logger.warning("Could not locate ComfyUI execution module, metadata collection disabled") print("Could not locate ComfyUI execution module, metadata collection disabled")
return return
# Detect whether we're using the new async version of ComfyUI # Detect whether we're using the new async version of ComfyUI
@@ -40,16 +37,16 @@ class MetadataHook:
is_async = inspect.iscoroutinefunction(execution._map_node_over_list) is_async = inspect.iscoroutinefunction(execution._map_node_over_list)
if is_async: if is_async:
logger.info("Detected async ComfyUI execution, installing async metadata hooks") print("Detected async ComfyUI execution, installing async metadata hooks")
MetadataHook._install_async_hooks(execution, map_node_func_name) MetadataHook._install_async_hooks(execution, map_node_func_name)
else: else:
logger.info("Detected sync ComfyUI execution, installing sync metadata hooks") print("Detected sync ComfyUI execution, installing sync metadata hooks")
MetadataHook._install_sync_hooks(execution) MetadataHook._install_sync_hooks(execution)
logger.info("Metadata collection hooks installed for runtime values") print("Metadata collection hooks installed for runtime values")
except Exception as e: except Exception as e:
logger.error(f"Error installing metadata hooks: {str(e)}") print(f"Error installing metadata hooks: {str(e)}")
@staticmethod @staticmethod
def _install_sync_hooks(execution): def _install_sync_hooks(execution):
@@ -85,7 +82,7 @@ class MetadataHook:
if node_id is not None: if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None) registry.record_node_execution(node_id, class_type, input_data_all, None)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}") print(f"Error collecting metadata (pre-execution): {str(e)}")
# Execute the original function # Execute the original function
results = original_map_node_over_list(obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb) results = original_map_node_over_list(obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb)
@@ -116,7 +113,7 @@ class MetadataHook:
if node_id is not None: if node_id is not None:
registry.update_node_execution(node_id, class_type, results) registry.update_node_execution(node_id, class_type, results)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (post-execution): {str(e)}") print(f"Error collecting metadata (post-execution): {str(e)}")
return results return results
@@ -149,12 +146,9 @@ class MetadataHook:
# Store the original _async_map_node_over_list function # Store the original _async_map_node_over_list function
original_map_node_over_list = getattr(execution, map_node_func_name) original_map_node_over_list = getattr(execution, map_node_func_name)
# Wrapped async function - signature must exactly match _async_map_node_over_list # Wrapped async function, compatible with both stable and nightly
async def async_map_node_over_list_with_metadata( async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None, *args, **kwargs):
prompt_id, unique_id, obj, input_data_all, func, hidden_inputs = kwargs.get('hidden_inputs', None)
allow_interrupt=False, execution_block_cb=None,
pre_execute_cb=None, v3_data=None
):
# Only collect metadata when calling the main function of nodes # Only collect metadata when calling the main function of nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):
try: try:
@@ -165,12 +159,12 @@ class MetadataHook:
if node_id is not None: if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None) registry.record_node_execution(node_id, class_type, input_data_all, None)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}") print(f"Error collecting metadata (pre-execution): {str(e)}")
# Call original function with exact parameters # Call original function with all args/kwargs
results = await original_map_node_over_list( results = await original_map_node_over_list(
prompt_id, unique_id, obj, input_data_all, func, prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt, execution_block_cb, pre_execute_cb, v3_data=v3_data allow_interrupt, execution_block_cb, pre_execute_cb, *args, **kwargs
) )
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):
@@ -182,7 +176,7 @@ class MetadataHook:
if node_id is not None: if node_id is not None:
registry.update_node_execution(node_id, class_type, results) registry.update_node_execution(node_id, class_type, results)
except Exception as e: except Exception as e:
logger.error(f"Error collecting metadata (post-execution): {str(e)}") print(f"Error collecting metadata (post-execution): {str(e)}")
return results return results

View File

@@ -1,12 +1,10 @@
import time import time
from nodes import NODE_CLASS_MAPPINGS # type: ignore from nodes import NODE_CLASS_MAPPINGS
from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor from .node_extractors import NODE_EXTRACTORS, GenericNodeExtractor
from .constants import METADATA_CATEGORIES, IMAGES from .constants import METADATA_CATEGORIES, IMAGES
class MetadataRegistry: class MetadataRegistry:
"""A singleton registry to store and retrieve workflow metadata""" """A singleton registry to store and retrieve workflow metadata"""
_instance = None _instance = None
def __new__(cls): def __new__(cls):
@@ -39,13 +37,11 @@ class MetadataRegistry:
# Sort all prompt_ids by timestamp # Sort all prompt_ids by timestamp
sorted_prompts = sorted( sorted_prompts = sorted(
self.prompt_metadata.keys(), self.prompt_metadata.keys(),
key=lambda pid: self.prompt_metadata[pid].get("timestamp", 0), key=lambda pid: self.prompt_metadata[pid].get("timestamp", 0)
) )
# Remove oldest records # Remove oldest records
prompts_to_remove = sorted_prompts[ prompts_to_remove = sorted_prompts[:len(sorted_prompts) - self.max_prompt_history]
: len(sorted_prompts) - self.max_prompt_history
]
for pid in prompts_to_remove: for pid in prompts_to_remove:
del self.prompt_metadata[pid] del self.prompt_metadata[pid]
@@ -57,13 +53,11 @@ class MetadataRegistry:
category: {} for category in METADATA_CATEGORIES category: {} for category in METADATA_CATEGORIES
} }
# Add additional metadata fields # Add additional metadata fields
self.prompt_metadata[prompt_id].update( self.prompt_metadata[prompt_id].update({
{
"execution_order": [], "execution_order": [],
"current_prompt": None, # Will store the prompt object "current_prompt": None, # Will store the prompt object
"timestamp": time.time(), "timestamp": time.time()
} })
)
# Clean up old prompt data # Clean up old prompt data
self._clean_old_prompts() self._clean_old_prompts()
@@ -131,9 +125,7 @@ class MetadataRegistry:
for category in self.metadata_categories: for category in self.metadata_categories:
if category in cached_data and node_id in cached_data[category]: if category in cached_data and node_id in cached_data[category]:
if node_id not in metadata[category]: if node_id not in metadata[category]:
metadata[category][node_id] = cached_data[category][ metadata[category][node_id] = cached_data[category][node_id]
node_id
]
def record_node_execution(self, node_id, class_type, inputs, outputs): def record_node_execution(self, node_id, class_type, inputs, outputs):
"""Record information about a node's execution""" """Record information about a node's execution"""
@@ -143,9 +135,7 @@ class MetadataRegistry:
# Add to execution order and mark as executed # Add to execution order and mark as executed
if node_id not in self.executed_nodes: if node_id not in self.executed_nodes:
self.executed_nodes.add(node_id) self.executed_nodes.add(node_id)
self.prompt_metadata[self.current_prompt_id]["execution_order"].append( self.prompt_metadata[self.current_prompt_id]["execution_order"].append(node_id)
node_id
)
# Process inputs to simplify working with them # Process inputs to simplify working with them
processed_inputs = {} processed_inputs = {}
@@ -162,7 +152,7 @@ class MetadataRegistry:
node_id, node_id,
processed_inputs, processed_inputs,
outputs, outputs,
self.prompt_metadata[self.current_prompt_id], self.prompt_metadata[self.current_prompt_id]
) )
# Cache this node's metadata # Cache this node's metadata
@@ -178,9 +168,11 @@ class MetadataRegistry:
# Use the same extractor to update with outputs # Use the same extractor to update with outputs
extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor) extractor = NODE_EXTRACTORS.get(class_type, GenericNodeExtractor)
if hasattr(extractor, "update"): if hasattr(extractor, 'update'):
extractor.update( extractor.update(
node_id, processed_outputs, self.prompt_metadata[self.current_prompt_id] node_id,
processed_outputs,
self.prompt_metadata[self.current_prompt_id]
) )
# Update the cached metadata for this node # Update the cached metadata for this node
@@ -222,7 +214,7 @@ class MetadataRegistry:
# Find cache keys that are no longer needed # Find cache keys that are no longer needed
keys_to_remove = [] keys_to_remove = []
for cache_key in self.node_cache: for cache_key in self.node_cache:
node_id = cache_key.split(":")[0] node_id = cache_key.split(':')[0]
if node_id not in active_node_ids: if node_id not in active_node_ids:
keys_to_remove.append(cache_key) keys_to_remove.append(cache_key)
@@ -278,10 +270,7 @@ class MetadataRegistry:
if IMAGES in cached_data and node_id in cached_data[IMAGES]: if IMAGES in cached_data and node_id in cached_data[IMAGES]:
image_data = cached_data[IMAGES][node_id]["image"] image_data = cached_data[IMAGES][node_id]["image"]
# Handle different image formats # Handle different image formats
if ( if isinstance(image_data, (list, tuple)) and len(image_data) > 0:
isinstance(image_data, (list, tuple))
and len(image_data) > 0
):
return image_data[0] return image_data[0]
return image_data return image_data

View File

@@ -1,118 +0,0 @@
import logging
from typing import List, Tuple
import comfy.sd # type: ignore
import folder_paths # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class CheckpointLoaderLM:
"""Checkpoint Loader with support for extra folder paths
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for checkpoint loading.
"""
NAME = "Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(s):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = s._get_checkpoint_names()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE")
RETURN_NAMES = ("MODEL", "CLIP", "VAE")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The CLIP model used for encoding text prompts.",
"The VAE model used for encoding and decoding images to and from latent space.",
)
FUNCTION = "load_checkpoint"
@classmethod
def _get_checkpoint_names(cls) -> List[str]:
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)"""
try:
from ..services.service_registry import ServiceRegistry
import asyncio
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") == "checkpoint":
file_path = item.get("file_path", "")
if file_path:
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
try:
loop = asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_names())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(_get_names())
except Exception as e:
logger.error(f"Error getting checkpoint names: {e}")
return []
def load_checkpoint(self, ckpt_name: str) -> Tuple:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
Returns:
Tuple of (MODEL, CLIP, VAE)
"""
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
if metadata is None:
raise FileNotFoundError(
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
"Make sure the checkpoint is indexed and try again."
)
# Load regular checkpoint using ComfyUI's API
logger.info(f"Loading checkpoint from: {ckpt_path}")
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
return out[:3]

View File

@@ -1,161 +0,0 @@
"""
Helper module to safely import ComfyUI-GGUF modules.
This module provides a robust way to import ComfyUI-GGUF functionality
regardless of how ComfyUI loaded it.
"""
import sys
import os
import importlib.util
import logging
from typing import Optional, Tuple, Any
logger = logging.getLogger(__name__)
def _get_gguf_path() -> str:
"""Get the path to ComfyUI-GGUF based on this file's location.
Since ComfyUI-Lora-Manager and ComfyUI-GGUF are both in custom_nodes/,
we can derive the GGUF path from our own location.
"""
# This file is at: custom_nodes/ComfyUI-Lora-Manager/py/nodes/gguf_import_helper.py
# ComfyUI-GGUF is at: custom_nodes/ComfyUI-GGUF
current_file = os.path.abspath(__file__)
# Go up 4 levels: nodes -> py -> ComfyUI-Lora-Manager -> custom_nodes
custom_nodes_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
return os.path.join(custom_nodes_dir, "ComfyUI-GGUF")
def _find_gguf_module() -> Optional[Any]:
"""Find ComfyUI-GGUF module in sys.modules.
ComfyUI registers modules using the full path with dots replaced by _x_.
"""
gguf_path = _get_gguf_path()
sys_module_name = gguf_path.replace(".", "_x_")
logger.debug(f"[GGUF Import] Looking for module '{sys_module_name}' in sys.modules")
if sys_module_name in sys.modules:
logger.info(f"[GGUF Import] Found module: '{sys_module_name}'")
return sys.modules[sys_module_name]
logger.debug(f"[GGUF Import] Module not found: '{sys_module_name}'")
return None
def _load_gguf_modules_directly() -> Optional[Any]:
"""Load ComfyUI-GGUF modules directly from file paths."""
gguf_path = _get_gguf_path()
logger.info(f"[GGUF Import] Direct Load: Attempting to load from '{gguf_path}'")
if not os.path.exists(gguf_path):
logger.warning(f"[GGUF Import] Path does not exist: {gguf_path}")
return None
try:
namespace = "ComfyUI_GGUF_Dynamic"
init_path = os.path.join(gguf_path, "__init__.py")
if not os.path.exists(init_path):
logger.warning(f"[GGUF Import] __init__.py not found at '{init_path}'")
return None
logger.debug(f"[GGUF Import] Loading from '{init_path}'")
spec = importlib.util.spec_from_file_location(namespace, init_path)
if not spec or not spec.loader:
logger.error(f"[GGUF Import] Failed to create spec for '{init_path}'")
return None
package = importlib.util.module_from_spec(spec)
package.__path__ = [gguf_path]
sys.modules[namespace] = package
spec.loader.exec_module(package)
logger.debug(f"[GGUF Import] Loaded main package '{namespace}'")
# Load submodules
loaded = []
for submod_name in ["loader", "ops", "nodes"]:
submod_path = os.path.join(gguf_path, f"{submod_name}.py")
if os.path.exists(submod_path):
submod_spec = importlib.util.spec_from_file_location(
f"{namespace}.{submod_name}", submod_path
)
if submod_spec and submod_spec.loader:
submod = importlib.util.module_from_spec(submod_spec)
submod.__package__ = namespace
sys.modules[f"{namespace}.{submod_name}"] = submod
submod_spec.loader.exec_module(submod)
setattr(package, submod_name, submod)
loaded.append(submod_name)
logger.debug(f"[GGUF Import] Loaded submodule '{submod_name}'")
logger.info(f"[GGUF Import] Direct Load success: {loaded}")
return package
except Exception as e:
logger.error(f"[GGUF Import] Direct Load failed: {e}", exc_info=True)
return None
def get_gguf_modules() -> Tuple[Any, Any, Any]:
"""Get ComfyUI-GGUF modules (loader, ops, nodes).
Returns:
Tuple of (loader_module, ops_module, nodes_module)
Raises:
RuntimeError: If ComfyUI-GGUF cannot be found or loaded.
"""
logger.debug("[GGUF Import] Starting module search...")
# Try to find already loaded module first
gguf_module = _find_gguf_module()
if gguf_module is None:
logger.info("[GGUF Import] Not found in sys.modules, trying direct load...")
gguf_module = _load_gguf_modules_directly()
if gguf_module is None:
raise RuntimeError(
"ComfyUI-GGUF is not installed. "
"Please install from https://github.com/city96/ComfyUI-GGUF"
)
# Extract submodules
loader = getattr(gguf_module, "loader", None)
ops = getattr(gguf_module, "ops", None)
nodes = getattr(gguf_module, "nodes", None)
if loader is None or ops is None or nodes is None:
missing = [
name
for name, mod in [("loader", loader), ("ops", ops), ("nodes", nodes)]
if mod is None
]
raise RuntimeError(f"ComfyUI-GGUF missing submodules: {missing}")
logger.debug("[GGUF Import] All modules loaded successfully")
return loader, ops, nodes
def get_gguf_sd_loader():
"""Get the gguf_sd_loader function from ComfyUI-GGUF."""
loader, _, _ = get_gguf_modules()
return getattr(loader, "gguf_sd_loader")
def get_ggml_ops():
"""Get the GGMLOps class from ComfyUI-GGUF."""
_, ops, _ = get_gguf_modules()
return getattr(ops, "GGMLOps")
def get_gguf_model_patcher():
"""Get the GGUFModelPatcher class from ComfyUI-GGUF."""
_, _, nodes = get_gguf_modules()
return getattr(nodes, "GGUFModelPatcher")

View File

@@ -56,9 +56,6 @@ class LoraCyclerLM:
clip_strength = float(cycler_config.get("clip_strength", 1.0)) clip_strength = float(cycler_config.get("clip_strength", 1.0))
sort_by = "filename" sort_by = "filename"
# Include "no lora" option
include_no_lora = cycler_config.get("include_no_lora", False)
# Dual-index mechanism for batch queue synchronization # Dual-index mechanism for batch queue synchronization
execution_index = cycler_config.get("execution_index") # Can be None execution_index = cycler_config.get("execution_index") # Can be None
# next_index_from_config = cycler_config.get("next_index") # Not used on backend # next_index_from_config = cycler_config.get("next_index") # Not used on backend
@@ -74,10 +71,7 @@ class LoraCyclerLM:
total_count = len(lora_list) total_count = len(lora_list)
# Calculate effective total count (includes no lora option if enabled) if total_count == 0:
effective_total_count = total_count + 1 if include_no_lora else total_count
if total_count == 0 and not include_no_lora:
logger.warning("[LoraCyclerLM] No LoRAs available in pool") logger.warning("[LoraCyclerLM] No LoRAs available in pool")
return { return {
"result": ([],), "result": ([],),
@@ -99,31 +93,15 @@ class LoraCyclerLM:
else: else:
actual_index = current_index actual_index = current_index
# Clamp index to valid range (1-based, includes no lora if enabled) # Clamp index to valid range (1-based)
clamped_index = max(1, min(actual_index, effective_total_count)) clamped_index = max(1, min(actual_index, total_count))
# Check if current index is the "no lora" option (last position when include_no_lora is True)
is_no_lora = include_no_lora and clamped_index == effective_total_count
if is_no_lora:
# "No LoRA" option - return empty stack
lora_stack = []
current_lora_name = "No LoRA"
current_lora_filename = "No LoRA"
else:
# Get LoRA at current index (convert to 0-based for list access) # Get LoRA at current index (convert to 0-based for list access)
current_lora = lora_list[clamped_index - 1] current_lora = lora_list[clamped_index - 1]
current_lora_name = current_lora["file_name"]
current_lora_filename = current_lora["file_name"]
# Build LORA_STACK with single LoRA # Build LORA_STACK with single LoRA
if current_lora["file_name"] == "None":
lora_path = None
else:
lora_path, _ = get_lora_info(current_lora["file_name"]) lora_path, _ = get_lora_info(current_lora["file_name"])
if not lora_path: if not lora_path:
if current_lora["file_name"] != "None":
logger.warning( logger.warning(
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}" f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
) )
@@ -135,30 +113,24 @@ class LoraCyclerLM:
# Calculate next index (wrap to 1 if at end) # Calculate next index (wrap to 1 if at end)
next_index = clamped_index + 1 next_index = clamped_index + 1
if next_index > effective_total_count: if next_index > total_count:
next_index = 1 next_index = 1
# Get next LoRA for UI display (what will be used next generation) # Get next LoRA for UI display (what will be used next generation)
is_next_no_lora = include_no_lora and next_index == effective_total_count
if is_next_no_lora:
next_display_name = "No LoRA"
next_lora_filename = "No LoRA"
else:
next_lora = lora_list[next_index - 1] next_lora = lora_list[next_index - 1]
next_display_name = next_lora["file_name"] next_display_name = next_lora["file_name"]
next_lora_filename = next_lora["file_name"]
return { return {
"result": (lora_stack,), "result": (lora_stack,),
"ui": { "ui": {
"current_index": [clamped_index], "current_index": [clamped_index],
"next_index": [next_index], "next_index": [next_index],
"total_count": [ "total_count": [total_count],
total_count "current_lora_name": [
], # Return actual LoRA count, not effective_total_count current_lora.get("model_name", current_lora["file_name"])
"current_lora_name": [current_lora_name], ],
"current_lora_filename": [current_lora_filename], "current_lora_filename": [current_lora["file_name"]],
"next_lora_name": [next_display_name], "next_lora_name": [next_display_name],
"next_lora_filename": [next_lora_filename], "next_lora_filename": [next_lora["file_name"]],
}, },
} }

View File

@@ -1,8 +1,7 @@
import logging import logging
import re import re
import comfy.utils # type: ignore from nodes import LoraLoader
import comfy.sd # type: ignore from ..utils.utils import get_lora_info
from ..utils.utils import get_lora_info_absolute
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,20 +52,18 @@ class LoraLoaderLM:
# First process lora_stack if available # First process lora_stack if available
if lora_stack: if lora_stack:
for lora_path, model_strength, clip_strength in lora_stack: for lora_path, model_strength, clip_strength in lora_stack:
# Extract lora name and convert to absolute path
# lora_stack stores relative paths, but load_torch_file needs absolute paths
lora_name = extract_lora_name(lora_path)
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Apply the LoRA using the appropriate loader # Apply the LoRA using the appropriate loader
if is_nunchaku_model: if is_nunchaku_model:
# Use our custom function for Flux models # Use our custom function for Flux models
model = nunchaku_load_lora(model, lora_path, model_strength) model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged for Nunchaku models # clip remains unchanged for Nunchaku models
else: else:
# Use lower-level API to load LoRA directly without folder_paths validation # Use default loader for standard models
lora = comfy.utils.load_torch_file(absolute_lora_path, safe_load=True) model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
# Extract lora name for trigger words lookup
lora_name = extract_lora_name(lora_path)
_, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words) all_trigger_words.extend(trigger_words)
# Add clip strength to output if different from model strength (except for Nunchaku models) # Add clip strength to output if different from model strength (except for Nunchaku models)
@@ -87,7 +84,7 @@ class LoraLoaderLM:
clip_strength = float(lora.get('clipStrength', model_strength)) clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words # Get lora path and trigger words
lora_path, trigger_words = get_lora_info_absolute(lora_name) lora_path, trigger_words = get_lora_info(lora_name)
# Apply the LoRA using the appropriate loader # Apply the LoRA using the appropriate loader
if is_nunchaku_model: if is_nunchaku_model:
@@ -95,9 +92,8 @@ class LoraLoaderLM:
model = nunchaku_load_lora(model, lora_path, model_strength) model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged # clip remains unchanged
else: else:
# Use lower-level API to load LoRA directly without folder_paths validation # Use default loader for standard models
lora = comfy.utils.load_torch_file(lora_path, safe_load=True) model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
# Include clip strength in output if different from model strength and not a Nunchaku model # Include clip strength in output if different from model strength and not a Nunchaku model
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001: if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
@@ -197,20 +193,18 @@ class LoraTextLoaderLM:
# First process lora_stack if available # First process lora_stack if available
if lora_stack: if lora_stack:
for lora_path, model_strength, clip_strength in lora_stack: for lora_path, model_strength, clip_strength in lora_stack:
# Extract lora name and convert to absolute path
# lora_stack stores relative paths, but load_torch_file needs absolute paths
lora_name = extract_lora_name(lora_path)
absolute_lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Apply the LoRA using the appropriate loader # Apply the LoRA using the appropriate loader
if is_nunchaku_model: if is_nunchaku_model:
# Use our custom function for Flux models # Use our custom function for Flux models
model = nunchaku_load_lora(model, lora_path, model_strength) model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged for Nunchaku models # clip remains unchanged for Nunchaku models
else: else:
# Use lower-level API to load LoRA directly without folder_paths validation # Use default loader for standard models
lora = comfy.utils.load_torch_file(absolute_lora_path, safe_load=True) model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
# Extract lora name for trigger words lookup
lora_name = extract_lora_name(lora_path)
_, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words) all_trigger_words.extend(trigger_words)
# Add clip strength to output if different from model strength (except for Nunchaku models) # Add clip strength to output if different from model strength (except for Nunchaku models)
@@ -227,7 +221,7 @@ class LoraTextLoaderLM:
clip_strength = lora['clip_strength'] clip_strength = lora['clip_strength']
# Get lora path and trigger words # Get lora path and trigger words
lora_path, trigger_words = get_lora_info_absolute(lora_name) lora_path, trigger_words = get_lora_info(lora_name)
# Apply the LoRA using the appropriate loader # Apply the LoRA using the appropriate loader
if is_nunchaku_model: if is_nunchaku_model:
@@ -235,9 +229,8 @@ class LoraTextLoaderLM:
model = nunchaku_load_lora(model, lora_path, model_strength) model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged # clip remains unchanged
else: else:
# Use lower-level API to load LoRA directly without folder_paths validation # Use default loader for standard models
lora = comfy.utils.load_torch_file(lora_path, safe_load=True) model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
model, clip = comfy.sd.load_lora_for_models(model, clip, lora, model_strength, clip_strength)
# Include clip strength in output if different from model strength and not a Nunchaku model # Include clip strength in output if different from model strength and not a Nunchaku model
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001: if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:

View File

@@ -82,7 +82,6 @@ class LoraPoolLM:
"folders": {"include": [], "exclude": []}, "folders": {"include": [], "exclude": []},
"favoritesOnly": False, "favoritesOnly": False,
"license": {"noCreditRequired": False, "allowSelling": False}, "license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
}, },
"preview": {"matchCount": 0, "lastUpdated": 0}, "preview": {"matchCount": 0, "lastUpdated": 0},
} }

View File

@@ -7,8 +7,10 @@ and tracks the last used combination for reuse.
""" """
import logging import logging
import random
import os import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
from .utils import extract_lora_name
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -1,16 +1,4 @@
from typing import Any from typing import Any, Optional
import inspect
class _AllContainer:
"""Container that accepts any key for dynamic input validation."""
def __contains__(self, item):
return True
def __getitem__(self, key):
return ("STRING", {"forceInput": True})
class PromptLM: class PromptLM:
"""Encodes text (and optional trigger words) into CLIP conditioning.""" """Encodes text (and optional trigger words) into CLIP conditioning."""
@@ -20,26 +8,10 @@ class PromptLM:
DESCRIPTION = ( DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used " "Encodes a text prompt using a CLIP model into an embedding that can be used "
"to guide the diffusion model towards generating specific images." "to guide the diffusion model towards generating specific images."
"Supports dynamic trigger words inputs."
) )
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
dyn_inputs = {
"trigger_words1": (
"STRING",
{
"forceInput": True,
"tooltip": "Trigger words to prepend. Connect to add more inputs.",
},
),
}
# Bypass validation for dynamic inputs during graph execution
stack = inspect.stack()
if len(stack) > 2 and stack[2].function == "get_input_info":
dyn_inputs = _AllContainer()
return { return {
"required": { "required": {
"text": ( "text": (
@@ -51,34 +23,36 @@ class PromptLM:
}, },
), ),
"clip": ( "clip": (
"CLIP", 'CLIP',
{"tooltip": "The CLIP model used for encoding the text."}, {"tooltip": "The CLIP model used for encoding the text."},
), ),
}, },
"optional": dyn_inputs, "optional": {
"trigger_words": (
'STRING',
{
"forceInput": True,
"tooltip": (
"Optional trigger words to prepend to the text before "
"encoding."
)
},
)
},
} }
RETURN_TYPES = ("CONDITIONING", "STRING") RETURN_TYPES = ('CONDITIONING', 'STRING',)
RETURN_NAMES = ("CONDITIONING", "PROMPT") RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
OUTPUT_TOOLTIPS = ( OUTPUT_TOOLTIPS = (
"A conditioning containing the embedded text used to guide the diffusion model.", "A conditioning containing the embedded text used to guide the diffusion model.",
) )
FUNCTION = "encode" FUNCTION = "encode"
def encode(self, text: str, clip: Any, **kwargs): def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
# Collect all trigger words from dynamic inputs
trigger_words = []
for key, value in kwargs.items():
if key.startswith("trigger_words") and value:
trigger_words.append(value)
# Build final prompt
if trigger_words:
prompt = ", ".join(trigger_words + [text])
else:
prompt = text prompt = text
if trigger_words:
prompt = ", ".join([trigger_words, text])
from nodes import CLIPTextEncode # type: ignore from nodes import CLIPTextEncode # type: ignore
conditioning = CLIPTextEncode().encode(clip, prompt)[0] conditioning = CLIPTextEncode().encode(clip, prompt)[0]
return (conditioning, prompt) return (conditioning, prompt,)

View File

@@ -1,7 +1,6 @@
import json import json
import os import os
import re import re
from typing import Any, Dict, Optional
import numpy as np import numpy as np
import folder_paths # type: ignore import folder_paths # type: ignore
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
@@ -9,10 +8,6 @@ from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata from ..metadata_collector import get_metadata
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
import piexif import piexif
import logging
logger = logging.getLogger(__name__)
class SaveImageLM: class SaveImageLM:
NAME = "Save Image (LoraManager)" NAME = "Save Image (LoraManager)"
@@ -34,51 +29,33 @@ class SaveImageLM:
return { return {
"required": { "required": {
"images": ("IMAGE",), "images": ("IMAGE",),
"filename_prefix": ( "filename_prefix": ("STRING", {
"STRING",
{
"default": "ComfyUI", "default": "ComfyUI",
"tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc.", "tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc."
}, }),
), "file_format": (["png", "jpeg", "webp"], {
"file_format": (
["png", "jpeg", "webp"],
{
"tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality." "tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality."
}, }),
),
}, },
"optional": { "optional": {
"lossless_webp": ( "lossless_webp": ("BOOLEAN", {
"BOOLEAN",
{
"default": False, "default": False,
"tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss.", "tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss."
}, }),
), "quality": ("INT", {
"quality": (
"INT",
{
"default": 100, "default": 100,
"min": 1, "min": 1,
"max": 100, "max": 100,
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files.", "tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files."
}, }),
), "embed_workflow": ("BOOLEAN", {
"embed_workflow": (
"BOOLEAN",
{
"default": False, "default": False,
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats.", "tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats."
}, }),
), "add_counter_to_filename": ("BOOLEAN", {
"add_counter_to_filename": (
"BOOLEAN",
{
"default": True, "default": True,
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.", "tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images."
}, }),
),
}, },
"hidden": { "hidden": {
"id": "UNIQUE_ID", "id": "UNIQUE_ID",
@@ -97,7 +74,6 @@ class SaveImageLM:
scanner = ServiceRegistry.get_service_sync("lora_scanner") scanner = ServiceRegistry.get_service_sync("lora_scanner")
# Use the new direct filename lookup method # Use the new direct filename lookup method
if scanner is not None:
hash_value = scanner.get_hash_by_filename(lora_name) hash_value = scanner.get_hash_by_filename(lora_name)
if hash_value: if hash_value:
return hash_value return hash_value
@@ -116,7 +92,6 @@ class SaveImageLM:
checkpoint_name = os.path.splitext(checkpoint_name)[0] checkpoint_name = os.path.splitext(checkpoint_name)[0]
# Try direct filename lookup first # Try direct filename lookup first
if scanner is not None:
hash_value = scanner.get_hash_by_filename(checkpoint_name) hash_value = scanner.get_hash_by_filename(checkpoint_name)
if hash_value: if hash_value:
return hash_value return hash_value
@@ -134,11 +109,11 @@ class SaveImageLM:
param_list.append(f"{label}: {value}") param_list.append(f"{label}: {value}")
# Extract the prompt and negative prompt # Extract the prompt and negative prompt
prompt = metadata_dict.get("prompt", "") prompt = metadata_dict.get('prompt', '')
negative_prompt = metadata_dict.get("negative_prompt", "") negative_prompt = metadata_dict.get('negative_prompt', '')
# Extract loras from the prompt if present # Extract loras from the prompt if present
loras_text = metadata_dict.get("loras", "") loras_text = metadata_dict.get('loras', '')
lora_hashes = {} lora_hashes = {}
# If loras are found, add them on a new line after the prompt # If loras are found, add them on a new line after the prompt
@@ -146,7 +121,7 @@ class SaveImageLM:
prompt_with_loras = f"{prompt}\n{loras_text}" prompt_with_loras = f"{prompt}\n{loras_text}"
# Extract lora names from the format <lora:name:strength> # Extract lora names from the format <lora:name:strength>
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", loras_text) lora_matches = re.findall(r'<lora:([^:]+):([^>]+)>', loras_text)
# Get hash for each lora # Get hash for each lora
for lora_name, strength in lora_matches: for lora_name, strength in lora_matches:
@@ -167,43 +142,43 @@ class SaveImageLM:
params = [] params = []
# Add standard parameters in the correct order # Add standard parameters in the correct order
if "steps" in metadata_dict: if 'steps' in metadata_dict:
add_param_if_not_none(params, "Steps", metadata_dict.get("steps")) add_param_if_not_none(params, "Steps", metadata_dict.get('steps'))
# Combine sampler and scheduler information # Combine sampler and scheduler information
sampler_name = None sampler_name = None
scheduler_name = None scheduler_name = None
if "sampler" in metadata_dict: if 'sampler' in metadata_dict:
sampler = metadata_dict.get("sampler") sampler = metadata_dict.get('sampler')
# Convert ComfyUI sampler names to user-friendly names # Convert ComfyUI sampler names to user-friendly names
sampler_mapping = { sampler_mapping = {
"euler": "Euler", 'euler': 'Euler',
"euler_ancestral": "Euler a", 'euler_ancestral': 'Euler a',
"dpm_2": "DPM2", 'dpm_2': 'DPM2',
"dpm_2_ancestral": "DPM2 a", 'dpm_2_ancestral': 'DPM2 a',
"heun": "Heun", 'heun': 'Heun',
"dpm_fast": "DPM fast", 'dpm_fast': 'DPM fast',
"dpm_adaptive": "DPM adaptive", 'dpm_adaptive': 'DPM adaptive',
"lms": "LMS", 'lms': 'LMS',
"dpmpp_2s_ancestral": "DPM++ 2S a", 'dpmpp_2s_ancestral': 'DPM++ 2S a',
"dpmpp_sde": "DPM++ SDE", 'dpmpp_sde': 'DPM++ SDE',
"dpmpp_sde_gpu": "DPM++ SDE", 'dpmpp_sde_gpu': 'DPM++ SDE',
"dpmpp_2m": "DPM++ 2M", 'dpmpp_2m': 'DPM++ 2M',
"dpmpp_2m_sde": "DPM++ 2M SDE", 'dpmpp_2m_sde': 'DPM++ 2M SDE',
"dpmpp_2m_sde_gpu": "DPM++ 2M SDE", 'dpmpp_2m_sde_gpu': 'DPM++ 2M SDE',
"ddim": "DDIM", 'ddim': 'DDIM'
} }
sampler_name = sampler_mapping.get(sampler, sampler) sampler_name = sampler_mapping.get(sampler, sampler)
if "scheduler" in metadata_dict: if 'scheduler' in metadata_dict:
scheduler = metadata_dict.get("scheduler") scheduler = metadata_dict.get('scheduler')
scheduler_mapping = { scheduler_mapping = {
"normal": "Simple", 'normal': 'Simple',
"karras": "Karras", 'karras': 'Karras',
"exponential": "Exponential", 'exponential': 'Exponential',
"sgm_uniform": "SGM Uniform", 'sgm_uniform': 'SGM Uniform',
"sgm_quadratic": "SGM Quadratic", 'sgm_quadratic': 'SGM Quadratic'
} }
scheduler_name = scheduler_mapping.get(scheduler, scheduler) scheduler_name = scheduler_mapping.get(scheduler, scheduler)
@@ -215,25 +190,25 @@ class SaveImageLM:
params.append(f"Sampler: {sampler_name}") params.append(f"Sampler: {sampler_name}")
# CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg) # CFG scale (Use guidance if available, otherwise fall back to cfg_scale or cfg)
if "guidance" in metadata_dict: if 'guidance' in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get("guidance")) add_param_if_not_none(params, "CFG scale", metadata_dict.get('guidance'))
elif "cfg_scale" in metadata_dict: elif 'cfg_scale' in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg_scale")) add_param_if_not_none(params, "CFG scale", metadata_dict.get('cfg_scale'))
elif "cfg" in metadata_dict: elif 'cfg' in metadata_dict:
add_param_if_not_none(params, "CFG scale", metadata_dict.get("cfg")) add_param_if_not_none(params, "CFG scale", metadata_dict.get('cfg'))
# Seed # Seed
if "seed" in metadata_dict: if 'seed' in metadata_dict:
add_param_if_not_none(params, "Seed", metadata_dict.get("seed")) add_param_if_not_none(params, "Seed", metadata_dict.get('seed'))
# Size # Size
if "size" in metadata_dict: if 'size' in metadata_dict:
add_param_if_not_none(params, "Size", metadata_dict.get("size")) add_param_if_not_none(params, "Size", metadata_dict.get('size'))
# Model info # Model info
if "checkpoint" in metadata_dict: if 'checkpoint' in metadata_dict:
# Ensure checkpoint is a string before processing # Ensure checkpoint is a string before processing
checkpoint = metadata_dict.get("checkpoint") checkpoint = metadata_dict.get('checkpoint')
if checkpoint is not None: if checkpoint is not None:
# Get model hash # Get model hash
model_hash = self.get_checkpoint_hash(checkpoint) model_hash = self.get_checkpoint_hash(checkpoint)
@@ -245,9 +220,7 @@ class SaveImageLM:
# Add model hash if available # Add model hash if available
if model_hash: if model_hash:
params.append( params.append(f"Model hash: {model_hash[:10]}, Model: {checkpoint_name}")
f"Model hash: {model_hash[:10]}, Model: {checkpoint_name}"
)
else: else:
params.append(f"Model: {checkpoint_name}") params.append(f"Model: {checkpoint_name}")
@@ -258,7 +231,7 @@ class SaveImageLM:
lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}") lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
if lora_hash_parts: if lora_hash_parts:
params.append(f'Lora hashes: "{", ".join(lora_hash_parts)}"') params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"")
# Combine all parameters with commas # Combine all parameters with commas
metadata_parts.append(", ".join(params)) metadata_parts.append(", ".join(params))
@@ -278,30 +251,30 @@ class SaveImageLM:
parts = segment.replace("%", "").split(":") parts = segment.replace("%", "").split(":")
key = parts[0] key = parts[0]
if key == "seed" and "seed" in metadata_dict: if key == "seed" and 'seed' in metadata_dict:
filename = filename.replace(segment, str(metadata_dict.get("seed", ""))) filename = filename.replace(segment, str(metadata_dict.get('seed', '')))
elif key == "width" and "size" in metadata_dict: elif key == "width" and 'size' in metadata_dict:
size = metadata_dict.get("size", "x") size = metadata_dict.get('size', 'x')
w = size.split("x")[0] if isinstance(size, str) else size[0] w = size.split('x')[0] if isinstance(size, str) else size[0]
filename = filename.replace(segment, str(w)) filename = filename.replace(segment, str(w))
elif key == "height" and "size" in metadata_dict: elif key == "height" and 'size' in metadata_dict:
size = metadata_dict.get("size", "x") size = metadata_dict.get('size', 'x')
h = size.split("x")[1] if isinstance(size, str) else size[1] h = size.split('x')[1] if isinstance(size, str) else size[1]
filename = filename.replace(segment, str(h)) filename = filename.replace(segment, str(h))
elif key == "pprompt" and "prompt" in metadata_dict: elif key == "pprompt" and 'prompt' in metadata_dict:
prompt = metadata_dict.get("prompt", "").replace("\n", " ") prompt = metadata_dict.get('prompt', '').replace("\n", " ")
if len(parts) >= 2: if len(parts) >= 2:
length = int(parts[1]) length = int(parts[1])
prompt = prompt[:length] prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip()) filename = filename.replace(segment, prompt.strip())
elif key == "nprompt" and "negative_prompt" in metadata_dict: elif key == "nprompt" and 'negative_prompt' in metadata_dict:
prompt = metadata_dict.get("negative_prompt", "").replace("\n", " ") prompt = metadata_dict.get('negative_prompt', '').replace("\n", " ")
if len(parts) >= 2: if len(parts) >= 2:
length = int(parts[1]) length = int(parts[1])
prompt = prompt[:length] prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip()) filename = filename.replace(segment, prompt.strip())
elif key == "model": elif key == "model":
model_value = metadata_dict.get("checkpoint") model_value = metadata_dict.get('checkpoint')
if isinstance(model_value, (bytes, os.PathLike)): if isinstance(model_value, (bytes, os.PathLike)):
model_value = str(model_value) model_value = str(model_value)
@@ -315,7 +288,6 @@ class SaveImageLM:
filename = filename.replace(segment, model) filename = filename.replace(segment, model)
elif key == "date": elif key == "date":
from datetime import datetime from datetime import datetime
now = datetime.now() now = datetime.now()
date_table = { date_table = {
"yyyy": f"{now.year:04d}", "yyyy": f"{now.year:04d}",
@@ -339,19 +311,8 @@ class SaveImageLM:
return filename return filename
def save_images( def save_images(self, images, filename_prefix, file_format, id, prompt=None, extra_pnginfo=None,
self, lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
images,
filename_prefix,
file_format,
id,
prompt=None,
extra_pnginfo=None,
lossless_webp=True,
quality=100,
embed_workflow=False,
add_counter_to_filename=True,
):
"""Save images with metadata""" """Save images with metadata"""
results = [] results = []
@@ -365,11 +326,9 @@ class SaveImageLM:
filename_prefix = self.format_filename(filename_prefix, metadata_dict) filename_prefix = self.format_filename(filename_prefix, metadata_dict)
# Get initial save path info once for the batch # Get initial save path info once for the batch
full_output_folder, filename, counter, subfolder, processed_prefix = ( full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path(
folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0] filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
) )
)
# Create directory if it doesn't exist # Create directory if it doesn't exist
if not os.path.exists(full_output_folder): if not os.path.exists(full_output_folder):
@@ -378,7 +337,7 @@ class SaveImageLM:
# Process each image with incrementing counter # Process each image with incrementing counter
for i, image in enumerate(images): for i, image in enumerate(images):
# Convert the tensor image to numpy array # Convert the tensor image to numpy array
img = 255.0 * image.cpu().numpy() img = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)) img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed # Generate filename with counter if needed
@@ -389,9 +348,6 @@ class SaveImageLM:
base_filename += f"_{current_counter:05}_" base_filename += f"_{current_counter:05}_"
# Set file extension and prepare saving parameters # Set file extension and prepare saving parameters
file: str
save_kwargs: Dict[str, Any]
pnginfo: Optional[PngImagePlugin.PngInfo] = None
if file_format == "png": if file_format == "png":
file = base_filename + ".png" file = base_filename + ".png"
file_extension = ".png" file_extension = ".png"
@@ -406,13 +362,7 @@ class SaveImageLM:
file = base_filename + ".webp" file = base_filename + ".webp"
file_extension = ".webp" file_extension = ".webp"
# Add optimization param to control performance # Add optimization param to control performance
save_kwargs = { save_kwargs = {"quality": quality, "lossless": lossless_webp, "method": 0}
"quality": quality,
"lossless": lossless_webp,
"method": 0,
}
else:
raise ValueError(f"Unsupported file format: {file_format}")
# Full save path # Full save path
file_path = os.path.join(full_output_folder, file) file_path = os.path.join(full_output_folder, file)
@@ -420,7 +370,6 @@ class SaveImageLM:
# Save the image with metadata # Save the image with metadata
try: try:
if file_format == "png": if file_format == "png":
assert pnginfo is not None
if metadata: if metadata:
pnginfo.add_text("parameters", metadata) pnginfo.add_text("parameters", metadata)
if embed_workflow and extra_pnginfo is not None: if embed_workflow and extra_pnginfo is not None:
@@ -432,16 +381,11 @@ class SaveImageLM:
# For JPEG, use piexif # For JPEG, use piexif
if metadata: if metadata:
try: try:
exif_dict = { exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
"Exif": {
piexif.ExifIFD.UserComment: b"UNICODE\0"
+ metadata.encode("utf-16be")
}
}
exif_bytes = piexif.dump(exif_dict) exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes save_kwargs["exif"] = exif_bytes
except Exception as e: except Exception as e:
logger.error(f"Error adding EXIF data: {e}") print(f"Error adding EXIF data: {e}")
img.save(file_path, format="JPEG", **save_kwargs) img.save(file_path, format="JPEG", **save_kwargs)
elif file_format == "webp": elif file_format == "webp":
try: try:
@@ -449,48 +393,33 @@ class SaveImageLM:
exif_dict = {} exif_dict = {}
if metadata: if metadata:
exif_dict["Exif"] = { exif_dict['Exif'] = {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}
piexif.ExifIFD.UserComment: b"UNICODE\0"
+ metadata.encode("utf-16be")
}
# Add workflow if needed # Add workflow if needed
if embed_workflow and extra_pnginfo is not None: if embed_workflow and extra_pnginfo is not None:
workflow_json = json.dumps(extra_pnginfo["workflow"]) workflow_json = json.dumps(extra_pnginfo["workflow"])
exif_dict["0th"] = { exif_dict['0th'] = {piexif.ImageIFD.ImageDescription: "Workflow:" + workflow_json}
piexif.ImageIFD.ImageDescription: "Workflow:"
+ workflow_json
}
exif_bytes = piexif.dump(exif_dict) exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes save_kwargs["exif"] = exif_bytes
except Exception as e: except Exception as e:
logger.error(f"Error adding EXIF data: {e}") print(f"Error adding EXIF data: {e}")
img.save(file_path, format="WEBP", **save_kwargs) img.save(file_path, format="WEBP", **save_kwargs)
results.append( results.append({
{"filename": file, "subfolder": subfolder, "type": self.type} "filename": file,
) "subfolder": subfolder,
"type": self.type
})
except Exception as e: except Exception as e:
logger.error(f"Error saving image: {e}") print(f"Error saving image: {e}")
return results return results
def process_image( def process_image(self, images, id, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
self, lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
images,
id,
filename_prefix="ComfyUI",
file_format="png",
prompt=None,
extra_pnginfo=None,
lossless_webp=True,
quality=100,
embed_workflow=False,
add_counter_to_filename=True,
):
"""Process and save image with metadata""" """Process and save image with metadata"""
# Make sure the output directory exists # Make sure the output directory exists
os.makedirs(self.output_dir, exist_ok=True) os.makedirs(self.output_dir, exist_ok=True)
@@ -516,7 +445,7 @@ class SaveImageLM:
lossless_webp, lossless_webp,
quality, quality,
embed_workflow, embed_workflow,
add_counter_to_filename, add_counter_to_filename
) )
return (images,) return (images,)

View File

@@ -60,22 +60,6 @@ class TriggerWordToggleLM:
else: else:
return data return data
def _normalize_trigger_words(self, trigger_words):
"""Normalize trigger words by splitting by both single and double commas, stripping whitespace, and filtering empty strings"""
if not trigger_words or not isinstance(trigger_words, str):
return set()
# Split by double commas first to preserve groups, then by single commas
groups = re.split(r",{2,}", trigger_words)
words = []
for group in groups:
# Split each group by single comma
group_words = [word.strip() for word in group.split(",")]
words.extend(group_words)
# Filter out empty strings and return as set
return set(word for word in words if word)
def process_trigger_words( def process_trigger_words(
self, self,
id, id,
@@ -97,7 +81,7 @@ class TriggerWordToggleLM:
if ( if (
trigger_words_override trigger_words_override
and isinstance(trigger_words_override, str) and isinstance(trigger_words_override, str)
and self._normalize_trigger_words(trigger_words_override) != self._normalize_trigger_words(trigger_words) and trigger_words_override != trigger_words
): ):
filtered_triggers = trigger_words_override filtered_triggers = trigger_words_override
return (filtered_triggers,) return (filtered_triggers,)

View File

@@ -1,205 +0,0 @@
import logging
import os
from typing import List, Tuple
import comfy.sd # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class UNETLoaderLM:
"""UNET Loader with support for extra folder paths
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for UNET loading.
Supports both regular diffusion models and GGUF format models.
"""
NAME = "Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(s):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = s._get_unet_names()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
}
}
RETURN_TYPES = ("MODEL",)
RETURN_NAMES = ("MODEL",)
OUTPUT_TOOLTIPS = ("The model used for denoising latents.",)
FUNCTION = "load_unet"
@classmethod
def _get_unet_names(cls) -> List[str]:
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)"""
try:
from ..services.service_registry import ServiceRegistry
import asyncio
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") == "diffusion_model":
file_path = item.get("file_path", "")
if file_path:
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
try:
loop = asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(_get_names())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(_get_names())
except Exception as e:
logger.error(f"Error getting unet names: {e}")
return []
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL,)
"""
import torch
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
if metadata is None:
raise FileNotFoundError(
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
"Make sure the model is indexed and try again."
)
# Check if it's a GGUF model
if unet_path.endswith(".gguf"):
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
# Load regular diffusion model using ComfyUI's API
logger.info(f"Loading diffusion model from: {unet_path}")
# Build model options based on weight_dtype
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e4m3fn_fast":
model_options["dtype"] = torch.float8_e4m3fn
model_options["fp8_optimizations"] = True
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
return (model,)
def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple:
"""Load a GGUF format diffusion model
Args:
unet_path: Absolute path to the GGUF file
unet_name: Name of the model for error messages
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL,)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
try:
loader_module, ops_module, nodes_module = get_gguf_modules()
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
GGMLOps = getattr(ops_module, "GGMLOps")
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
except RuntimeError as e:
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
try:
# Load GGUF state dict
sd, extra = gguf_sd_loader(unet_path)
# Prepare kwargs for metadata if supported
kwargs = {}
import inspect
valid_params = inspect.signature(
comfy.sd.load_diffusion_model_state_dict
).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
# Setup custom operations with GGUF support
ops = GGMLOps()
# Handle weight_dtype for GGUF models
if weight_dtype in ("default", None):
ops.Linear.dequant_dtype = None
elif weight_dtype in ["target"]:
ops.Linear.dequant_dtype = weight_dtype
else:
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
# Load the model
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}, **kwargs
)
if model is None:
raise RuntimeError(
f"Could not detect model type for GGUF diffusion model: {unet_path}"
)
# Wrap with GGUFModelPatcher
model = GGUFModelPatcher.clone(model)
return (model,)
except Exception as e:
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
raise RuntimeError(
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
)

View File

@@ -4,7 +4,6 @@ class AnyType(str):
def __ne__(self, __value: object) -> bool: def __ne__(self, __value: object) -> bool:
return False return False
# Credit to Regis Gaughan, III (rgthree) # Credit to Regis Gaughan, III (rgthree)
class FlexibleOptionalInputType(dict): class FlexibleOptionalInputType(dict):
"""A special class to make flexible nodes that pass data to our python handlers. """A special class to make flexible nodes that pass data to our python handlers.
@@ -21,7 +20,6 @@ class FlexibleOptionalInputType(dict):
This should be forwards compatible unless more changes occur in the PR. This should be forwards compatible unless more changes occur in the PR.
""" """
def __init__(self, type): def __init__(self, type):
self.type = type self.type = type
@@ -39,27 +37,25 @@ import os
import logging import logging
import copy import copy
import sys import sys
import folder_paths # type: ignore import folder_paths
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def extract_lora_name(lora_path): def extract_lora_name(lora_path):
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')""" """Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
# Get the basename without extension # Get the basename without extension
basename = os.path.basename(lora_path) basename = os.path.basename(lora_path)
return os.path.splitext(basename)[0] return os.path.splitext(basename)[0]
def get_loras_list(kwargs): def get_loras_list(kwargs):
"""Helper to extract loras list from either old or new kwargs format""" """Helper to extract loras list from either old or new kwargs format"""
if "loras" not in kwargs: if 'loras' not in kwargs:
return [] return []
loras_data = kwargs["loras"] loras_data = kwargs['loras']
# Handle new format: {'loras': {'__value__': [...]}} # Handle new format: {'loras': {'__value__': [...]}}
if isinstance(loras_data, dict) and "__value__" in loras_data: if isinstance(loras_data, dict) and '__value__' in loras_data:
return loras_data["__value__"] return loras_data['__value__']
# Handle old format: {'loras': [...]} # Handle old format: {'loras': [...]}
elif isinstance(loras_data, list): elif isinstance(loras_data, list):
return loras_data return loras_data
@@ -68,25 +64,23 @@ def get_loras_list(kwargs):
logger.warning(f"Unexpected loras format: {type(loras_data)}") logger.warning(f"Unexpected loras format: {type(loras_data)}")
return [] return []
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""): def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path""" """Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
import safetensors.torch import safetensors.torch
state_dict = {} state_dict = {}
with safetensors.torch.safe_open(path, framework="pt", device=device) as f: # type: ignore[attr-defined] with safetensors.torch.safe_open(path, framework="pt", device=device) as f:
for k in f.keys(): for k in f.keys():
if filter_prefix and not k.startswith(filter_prefix): if filter_prefix and not k.startswith(filter_prefix):
continue continue
state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k) state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k)
return state_dict return state_dict
def to_diffusers(input_lora): def to_diffusers(input_lora):
"""Simplified version of to_diffusers for Flux LoRA conversion""" """Simplified version of to_diffusers for Flux LoRA conversion"""
import torch import torch
from diffusers.utils.state_dict_utils import convert_unet_state_dict_to_peft from diffusers.utils.state_dict_utils import convert_unet_state_dict_to_peft
from diffusers.loaders import FluxLoraLoaderMixin # type: ignore[attr-defined] from diffusers.loaders import FluxLoraLoaderMixin
if isinstance(input_lora, str): if isinstance(input_lora, str):
tensors = load_state_dict_in_safetensors(input_lora, device="cpu") tensors = load_state_dict_in_safetensors(input_lora, device="cpu")
@@ -103,15 +97,10 @@ def to_diffusers(input_lora):
return new_tensors return new_tensors
def nunchaku_load_lora(model, lora_name, lora_strength): def nunchaku_load_lora(model, lora_name, lora_strength):
"""Load a Flux LoRA for Nunchaku model""" """Load a Flux LoRA for Nunchaku model"""
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names. # Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
lora_path = ( lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
lora_name
if os.path.isfile(lora_name)
else folder_paths.get_full_path("loras", lora_name)
)
if not lora_path or not os.path.isfile(lora_path): if not lora_path or not os.path.isfile(lora_path):
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name) logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
return model return model
@@ -129,9 +118,7 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)] ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)]
else: else:
# Fallback to legacy logic # Fallback to legacy logic
logger.warning( logger.warning("Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic.")
"Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic."
)
transformer = model_wrapper.model transformer = model_wrapper.model
# Save the transformer temporarily # Save the transformer temporarily

View File

@@ -6,18 +6,17 @@ from .parsers import (
ComfyMetadataParser, ComfyMetadataParser,
MetaFormatParser, MetaFormatParser,
AutomaticMetadataParser, AutomaticMetadataParser,
CivitaiApiMetadataParser, CivitaiApiMetadataParser
) )
from .base import RecipeMetadataParser from .base import RecipeMetadataParser
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RecipeParserFactory: class RecipeParserFactory:
"""Factory for creating recipe metadata parsers""" """Factory for creating recipe metadata parsers"""
@staticmethod @staticmethod
def create_parser(metadata) -> RecipeMetadataParser | None: def create_parser(metadata) -> RecipeMetadataParser:
""" """
Create appropriate parser based on the metadata content Create appropriate parser based on the metadata content
@@ -39,7 +38,6 @@ class RecipeParserFactory:
# Convert dict to string for other parsers that expect string input # Convert dict to string for other parsers that expect string input
try: try:
import json import json
metadata_str = json.dumps(metadata) metadata_str = json.dumps(metadata)
except Exception as e: except Exception as e:
logger.debug(f"Failed to convert dict to JSON string: {e}") logger.debug(f"Failed to convert dict to JSON string: {e}")

View File

@@ -9,7 +9,6 @@ from ...services.metadata_service import get_default_metadata_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CivitaiApiMetadataParser(RecipeMetadataParser): class CivitaiApiMetadataParser(RecipeMetadataParser):
"""Parser for Civitai image metadata format""" """Parser for Civitai image metadata format"""
@@ -41,7 +40,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
"width", "width",
"height", "height",
"Model", "Model",
"Model hash", "Model hash"
) )
return any(key in payload for key in civitai_image_fields) return any(key in payload for key in civitai_image_fields)
@@ -51,9 +50,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Check for LoRA hash patterns # Check for LoRA hash patterns
hashes = metadata.get("hashes") hashes = metadata.get("hashes")
if isinstance(hashes, dict) and any( if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
str(key).lower().startswith("lora:") for key in hashes
):
return True return True
# Check nested meta object (common in CivitAI image responses) # Check nested meta object (common in CivitAI image responses)
@@ -64,28 +61,22 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Also check for LoRA hash patterns in nested meta # Also check for LoRA hash patterns in nested meta
hashes = nested_meta.get("hashes") hashes = nested_meta.get("hashes")
if isinstance(hashes, dict) and any( if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
str(key).lower().startswith("lora:") for key in hashes
):
return True return True
return False return False
async def parse_metadata( # type: ignore[override] async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
self, user_comment, recipe_scanner=None, civitai_client=None
) -> Dict[str, Any]:
"""Parse metadata from Civitai image format """Parse metadata from Civitai image format
Args: Args:
user_comment: The metadata from the image (dict) metadata: The metadata from the image (dict)
recipe_scanner: Optional recipe scanner service recipe_scanner: Optional recipe scanner service
civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead) civitai_client: Optional Civitai API client (deprecated, use metadata_provider instead)
Returns: Returns:
Dict containing parsed recipe data Dict containing parsed recipe data
""" """
metadata: Dict[str, Any] = user_comment # type: ignore[assignment]
metadata = user_comment
try: try:
# Get metadata provider instead of using civitai_client directly # Get metadata provider instead of using civitai_client directly
metadata_provider = await get_default_metadata_provider() metadata_provider = await get_default_metadata_provider()
@@ -112,11 +103,11 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Initialize result structure # Initialize result structure
result = { result = {
"base_model": None, 'base_model': None,
"loras": [], 'loras': [],
"model": None, 'model': None,
"gen_params": {}, 'gen_params': {},
"from_civitai_image": True, 'from_civitai_image': True
} }
# Track already added LoRAs to prevent duplicates # Track already added LoRAs to prevent duplicates
@@ -157,25 +148,16 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["base_model"] = metadata["baseModel"] result["base_model"] = metadata["baseModel"]
elif "Model hash" in metadata and metadata_provider: elif "Model hash" in metadata and metadata_provider:
model_hash = metadata["Model hash"] model_hash = metadata["Model hash"]
model_info, error = await metadata_provider.get_model_by_hash( model_info, error = await metadata_provider.get_model_by_hash(model_hash)
model_hash
)
if model_info: if model_info:
result["base_model"] = model_info.get("baseModel", "") result["base_model"] = model_info.get("baseModel", "")
elif "Model" in metadata and isinstance(metadata.get("resources"), list): elif "Model" in metadata and isinstance(metadata.get("resources"), list):
# Try to find base model in resources # Try to find base model in resources
for resource in metadata.get("resources", []): for resource in metadata.get("resources", []):
if resource.get("type") == "model" and resource.get( if resource.get("type") == "model" and resource.get("name") == metadata.get("Model"):
"name"
) == metadata.get("Model"):
# This is likely the checkpoint model # This is likely the checkpoint model
if metadata_provider and resource.get("hash"): if metadata_provider and resource.get("hash"):
( model_info, error = await metadata_provider.get_model_by_hash(resource.get("hash"))
model_info,
error,
) = await metadata_provider.get_model_by_hash(
resource.get("hash")
)
if model_info: if model_info:
result["base_model"] = model_info.get("baseModel", "") result["base_model"] = model_info.get("baseModel", "")
@@ -194,9 +176,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Skip LoRAs without proper identification (hash or modelVersionId) # Skip LoRAs without proper identification (hash or modelVersionId)
if not lora_hash and not resource.get("modelVersionId"): if not lora_hash and not resource.get("modelVersionId"):
logger.debug( logger.debug(f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId")
f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId"
)
continue continue
# Skip if we've already added this LoRA by hash # Skip if we've already added this LoRA by hash
@@ -204,33 +184,31 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
"name": resource.get("name", "Unknown LoRA"), 'name': resource.get("name", "Unknown LoRA"),
"type": "lora", 'type': "lora",
"weight": float(resource.get("weight", 1.0)), 'weight': float(resource.get("weight", 1.0)),
"hash": lora_hash, 'hash': lora_hash,
"existsLocally": False, 'existsLocally': False,
"localPath": None, 'localPath': None,
"file_name": resource.get("name", "Unknown"), 'file_name': resource.get("name", "Unknown"),
"thumbnailUrl": "/loras_static/images/no-preview.png", 'thumbnailUrl': '/loras_static/images/no-preview.png',
"baseModel": "", 'baseModel': '',
"size": 0, 'size': 0,
"downloadUrl": "", 'downloadUrl': '',
"isDeleted": False, 'isDeleted': False
} }
# Try to get info from Civitai if hash is available # Try to get info from Civitai if hash is available
if lora_entry["hash"] and metadata_provider: if lora_entry['hash'] and metadata_provider:
try: try:
civitai_info = ( civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
await metadata_provider.get_model_by_hash(lora_hash)
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts,
lora_hash, lora_hash
) )
if populated_entry is None: if populated_entry is None:
@@ -239,14 +217,10 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
# If we have a version ID from Civitai, track it for deduplication # If we have a version ID from Civitai, track it for deduplication
if "id" in lora_entry and lora_entry["id"]: if 'id' in lora_entry and lora_entry['id']:
added_loras[str(lora_entry["id"])] = len( added_loras[str(lora_entry['id'])] = len(result["loras"])
result["loras"]
)
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}")
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
)
# Track by hash if we have it # Track by hash if we have it
if lora_hash: if lora_hash:
@@ -255,9 +229,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process civitaiResources array # Process civitaiResources array
if "civitaiResources" in metadata and isinstance( if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
metadata["civitaiResources"], list
):
for resource in metadata["civitaiResources"]: for resource in metadata["civitaiResources"]:
# Get resource type and identifier # Get resource type and identifier
resource_type = str(resource.get("type") or "").lower() resource_type = str(resource.get("type") or "").lower()
@@ -265,39 +237,32 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource_type == "checkpoint": if resource_type == "checkpoint":
checkpoint_entry = { checkpoint_entry = {
"id": resource.get("modelVersionId", 0), 'id': resource.get("modelVersionId", 0),
"modelId": resource.get("modelId", 0), 'modelId': resource.get("modelId", 0),
"name": resource.get("modelName", "Unknown Checkpoint"), 'name': resource.get("modelName", "Unknown Checkpoint"),
"version": resource.get("modelVersionName", ""), 'version': resource.get("modelVersionName", ""),
"type": resource.get("type", "checkpoint"), 'type': resource.get("type", "checkpoint"),
"existsLocally": False, 'existsLocally': False,
"localPath": None, 'localPath': None,
"file_name": resource.get("modelName", ""), 'file_name': resource.get("modelName", ""),
"hash": resource.get("hash", "") or "", 'hash': resource.get("hash", "") or "",
"thumbnailUrl": "/loras_static/images/no-preview.png", 'thumbnailUrl': '/loras_static/images/no-preview.png',
"baseModel": "", 'baseModel': '',
"size": 0, 'size': 0,
"downloadUrl": "", 'downloadUrl': '',
"isDeleted": False, 'isDeleted': False
} }
if version_id and metadata_provider: if version_id and metadata_provider:
try: try:
civitai_info = ( civitai_info = await metadata_provider.get_model_version_info(version_id)
await metadata_provider.get_model_version_info(
version_id
)
)
checkpoint_entry = ( checkpoint_entry = await self.populate_checkpoint_from_civitai(
await self.populate_checkpoint_from_civitai( checkpoint_entry,
checkpoint_entry, civitai_info civitai_info
)
) )
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error fetching Civitai info for checkpoint version {version_id}: {e}")
f"Error fetching Civitai info for checkpoint version {version_id}: {e}"
)
if result["model"] is None: if result["model"] is None:
result["model"] = checkpoint_entry result["model"] = checkpoint_entry
@@ -310,35 +275,31 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# Initialize lora entry # Initialize lora entry
lora_entry = { lora_entry = {
"id": resource.get("modelVersionId", 0), 'id': resource.get("modelVersionId", 0),
"modelId": resource.get("modelId", 0), 'modelId': resource.get("modelId", 0),
"name": resource.get("modelName", "Unknown LoRA"), 'name': resource.get("modelName", "Unknown LoRA"),
"version": resource.get("modelVersionName", ""), 'version': resource.get("modelVersionName", ""),
"type": resource.get("type", "lora"), 'type': resource.get("type", "lora"),
"weight": round(float(resource.get("weight", 1.0)), 2), 'weight': round(float(resource.get("weight", 1.0)), 2),
"existsLocally": False, 'existsLocally': False,
"thumbnailUrl": "/loras_static/images/no-preview.png", 'thumbnailUrl': '/loras_static/images/no-preview.png',
"baseModel": "", 'baseModel': '',
"size": 0, 'size': 0,
"downloadUrl": "", 'downloadUrl': '',
"isDeleted": False, 'isDeleted': False
} }
# Try to get info from Civitai if modelVersionId is available # Try to get info from Civitai if modelVersionId is available
if version_id and metadata_provider: if version_id and metadata_provider:
try: try:
# Use get_model_version_info instead of get_model_version # Use get_model_version_info instead of get_model_version
civitai_info = ( civitai_info = await metadata_provider.get_model_version_info(version_id)
await metadata_provider.get_model_version_info(
version_id
)
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts
) )
if populated_entry is None: if populated_entry is None:
@@ -346,9 +307,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error fetching Civitai info for model version {version_id}: {e}")
f"Error fetching Civitai info for model version {version_id}: {e}"
)
# Track this LoRA in our deduplication dict # Track this LoRA in our deduplication dict
if version_id: if version_id:
@@ -357,15 +316,10 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Process additionalResources array # Process additionalResources array
if "additionalResources" in metadata and isinstance( if "additionalResources" in metadata and isinstance(metadata["additionalResources"], list):
metadata["additionalResources"], list
):
for resource in metadata["additionalResources"]: for resource in metadata["additionalResources"]:
# Skip resources that aren't LoRAs or LyCORIS # Skip resources that aren't LoRAs or LyCORIS
if ( if resource.get("type") not in ["lora", "lycoris"] and "type" not in resource:
resource.get("type") not in ["lora", "lycoris"]
and "type" not in resource
):
continue continue
lora_type = resource.get("type", "lora") lora_type = resource.get("type", "lora")
@@ -383,35 +337,31 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
"name": name, 'name': name,
"type": lora_type, 'type': lora_type,
"weight": float(resource.get("strength", 1.0)), 'weight': float(resource.get("strength", 1.0)),
"hash": "", 'hash': "",
"existsLocally": False, 'existsLocally': False,
"localPath": None, 'localPath': None,
"file_name": name, 'file_name': name,
"thumbnailUrl": "/loras_static/images/no-preview.png", 'thumbnailUrl': '/loras_static/images/no-preview.png',
"baseModel": "", 'baseModel': '',
"size": 0, 'size': 0,
"downloadUrl": "", 'downloadUrl': '',
"isDeleted": False, 'isDeleted': False
} }
# If we have a version ID and metadata provider, try to get more info # If we have a version ID and metadata provider, try to get more info
if version_id and metadata_provider: if version_id and metadata_provider:
try: try:
# Use get_model_version_info with the version ID # Use get_model_version_info with the version ID
civitai_info = ( civitai_info = await metadata_provider.get_model_version_info(version_id)
await metadata_provider.get_model_version_info(
version_id
)
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts
) )
if populated_entry is None: if populated_entry is None:
@@ -423,9 +373,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if version_id: if version_id:
added_loras[version_id] = len(result["loras"]) added_loras[version_id] = len(result["loras"])
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}")
f"Error fetching Civitai info for model ID {version_id}: {e}"
)
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
@@ -442,32 +390,30 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
"name": lora_name, 'name': lora_name,
"type": "lora", 'type': "lora",
"weight": 1.0, 'weight': 1.0,
"hash": lora_hash, 'hash': lora_hash,
"existsLocally": False, 'existsLocally': False,
"localPath": None, 'localPath': None,
"file_name": lora_name, 'file_name': lora_name,
"thumbnailUrl": "/loras_static/images/no-preview.png", 'thumbnailUrl': '/loras_static/images/no-preview.png',
"baseModel": "", 'baseModel': '',
"size": 0, 'size': 0,
"downloadUrl": "", 'downloadUrl': '',
"isDeleted": False, 'isDeleted': False
} }
if metadata_provider: if metadata_provider:
try: try:
civitai_info = await metadata_provider.get_model_by_hash( civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
lora_hash
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts,
lora_hash, lora_hash
) )
if populated_entry is None: if populated_entry is None:
@@ -475,27 +421,20 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
if "id" in lora_entry and lora_entry["id"]: if 'id' in lora_entry and lora_entry['id']:
added_loras[str(lora_entry["id"])] = len(result["loras"]) added_loras[str(lora_entry['id'])] = len(result["loras"])
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}")
f"Error fetching Civitai info for LoRA hash {lora_hash}: {e}"
)
added_loras[lora_hash] = len(result["loras"]) added_loras[lora_hash] = len(result["loras"])
result["loras"].append(lora_entry) result["loras"].append(lora_entry)
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc. # Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
lora_index = 0 lora_index = 0
while ( while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata:
f"Lora_{lora_index} Model hash" in metadata
and f"Lora_{lora_index} Model name" in metadata
):
lora_hash = metadata[f"Lora_{lora_index} Model hash"] lora_hash = metadata[f"Lora_{lora_index} Model hash"]
lora_name = metadata[f"Lora_{lora_index} Model name"] lora_name = metadata[f"Lora_{lora_index} Model name"]
lora_strength_model = float( lora_strength_model = float(metadata.get(f"Lora_{lora_index} Strength model", 1.0))
metadata.get(f"Lora_{lora_index} Strength model", 1.0)
)
# Skip if we've already added this LoRA by hash # Skip if we've already added this LoRA by hash
if lora_hash and lora_hash in added_loras: if lora_hash and lora_hash in added_loras:
@@ -503,33 +442,31 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
continue continue
lora_entry = { lora_entry = {
"name": lora_name, 'name': lora_name,
"type": "lora", 'type': "lora",
"weight": lora_strength_model, 'weight': lora_strength_model,
"hash": lora_hash, 'hash': lora_hash,
"existsLocally": False, 'existsLocally': False,
"localPath": None, 'localPath': None,
"file_name": lora_name, 'file_name': lora_name,
"thumbnailUrl": "/loras_static/images/no-preview.png", 'thumbnailUrl': '/loras_static/images/no-preview.png',
"baseModel": "", 'baseModel': '',
"size": 0, 'size': 0,
"downloadUrl": "", 'downloadUrl': '',
"isDeleted": False, 'isDeleted': False
} }
# Try to get info from Civitai if hash is available # Try to get info from Civitai if hash is available
if lora_entry["hash"] and metadata_provider: if lora_entry['hash'] and metadata_provider:
try: try:
civitai_info = await metadata_provider.get_model_by_hash( civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
lora_hash
)
populated_entry = await self.populate_lora_from_civitai( populated_entry = await self.populate_lora_from_civitai(
lora_entry, lora_entry,
civitai_info, civitai_info,
recipe_scanner, recipe_scanner,
base_model_counts, base_model_counts,
lora_hash, lora_hash
) )
if populated_entry is None: if populated_entry is None:
@@ -539,12 +476,10 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_entry = populated_entry lora_entry = populated_entry
# If we have a version ID from Civitai, track it for deduplication # If we have a version ID from Civitai, track it for deduplication
if "id" in lora_entry and lora_entry["id"]: if 'id' in lora_entry and lora_entry['id']:
added_loras[str(lora_entry["id"])] = len(result["loras"]) added_loras[str(lora_entry['id'])] = len(result["loras"])
except Exception as e: except Exception as e:
logger.error( logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}")
f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}"
)
# Track by hash if we have it # Track by hash if we have it
if lora_hash: if lora_hash:
@@ -556,9 +491,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
# If base model wasn't found earlier, use the most common one from LoRAs # If base model wasn't found earlier, use the most common one from LoRAs
if not result["base_model"] and base_model_counts: if not result["base_model"] and base_model_counts:
result["base_model"] = max( result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0]
base_model_counts.items(), key=lambda x: x[1]
)[0]
return result return result

View File

@@ -204,7 +204,6 @@ class BaseModelRoutes(ABC):
service=service, service=service,
update_service=update_service, update_service=update_service,
metadata_provider_selector=get_metadata_provider, metadata_provider_selector=get_metadata_provider,
settings_service=self._settings,
logger=logger, logger=logger,
) )
return ModelHandlerSet( return ModelHandlerSet(

View File

@@ -1,5 +1,4 @@
"""Base infrastructure shared across recipe routes.""" """Base infrastructure shared across recipe routes."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@@ -17,14 +16,12 @@ from ..services.recipes import (
RecipePersistenceService, RecipePersistenceService,
RecipeSharingService, RecipeSharingService,
) )
from ..services.batch_import_service import BatchImportService
from ..services.server_i18n import server_i18n from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..utils.constants import CARD_PREVIEW_WIDTH from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from .handlers.recipe_handlers import ( from .handlers.recipe_handlers import (
BatchImportHandler,
RecipeAnalysisHandler, RecipeAnalysisHandler,
RecipeHandlerSet, RecipeHandlerSet,
RecipeListingHandler, RecipeListingHandler,
@@ -119,10 +116,7 @@ class BaseRecipeRoutes:
recipe_scanner_getter = lambda: self.recipe_scanner recipe_scanner_getter = lambda: self.recipe_scanner
civitai_client_getter = lambda: self.civitai_client civitai_client_getter = lambda: self.civitai_client
standalone_mode = ( standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
)
if not standalone_mode: if not standalone_mode:
from ..metadata_collector import get_metadata # type: ignore[import-not-found] from ..metadata_collector import get_metadata # type: ignore[import-not-found]
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found] from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]
@@ -196,22 +190,6 @@ class BaseRecipeRoutes:
sharing_service=sharing_service, sharing_service=sharing_service,
) )
from ..services.websocket_manager import ws_manager
batch_import_service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
batch_import = BatchImportHandler(
ensure_dependencies_ready=self.ensure_dependencies_ready,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
logger=logger,
batch_import_service=batch_import_service,
)
return RecipeHandlerSet( return RecipeHandlerSet(
page_view=page_view, page_view=page_view,
listing=listing, listing=listing,
@@ -219,5 +197,4 @@ class BaseRecipeRoutes:
management=management, management=management,
analysis=analysis, analysis=analysis,
sharing=sharing, sharing=sharing,
batch_import=batch_import,
) )

View File

@@ -1,5 +1,5 @@
import logging import logging
from typing import Dict, List, Set from typing import Dict
from aiohttp import web from aiohttp import web
from .base_model_routes import BaseModelRoutes from .base_model_routes import BaseModelRoutes
@@ -82,22 +82,12 @@ class CheckpointRoutes(BaseModelRoutes):
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
async def get_checkpoints_roots(self, request: web.Request) -> web.Response: async def get_checkpoints_roots(self, request: web.Request) -> web.Response:
"""Return the list of checkpoint roots from config (including extra paths)""" """Return the list of checkpoint roots from config"""
try: try:
# Merge checkpoints_roots with extra_checkpoints_roots, preserving order and removing duplicates roots = config.checkpoints_roots
roots: List[str] = []
roots.extend(config.checkpoints_roots or [])
roots.extend(config.extra_checkpoints_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"roots": unique_roots "roots": roots
}) })
except Exception as e: except Exception as e:
logger.error(f"Error getting checkpoint roots: {e}", exc_info=True) logger.error(f"Error getting checkpoint roots: {e}", exc_info=True)
@@ -107,22 +97,12 @@ class CheckpointRoutes(BaseModelRoutes):
}, status=500) }, status=500)
async def get_unet_roots(self, request: web.Request) -> web.Response: async def get_unet_roots(self, request: web.Request) -> web.Response:
"""Return the list of unet roots from config (including extra paths)""" """Return the list of unet roots from config"""
try: try:
# Merge unet_roots with extra_unet_roots, preserving order and removing duplicates roots = config.unet_roots
roots: List[str] = []
roots.extend(config.unet_roots or [])
roots.extend(config.extra_unet_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return web.json_response({ return web.json_response({
"success": True, "success": True,
"roots": unique_roots "roots": roots
}) })
except Exception as e: except Exception as e:
logger.error(f"Error getting unet roots: {e}", exc_info=True) logger.error(f"Error getting unet roots: {e}", exc_info=True)

View File

@@ -30,7 +30,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"), RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"), RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"), RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"),
RouteDefinition("POST", "/api/lm/check-example-images-needed", "check_example_images_needed"),
) )

View File

@@ -1,14 +1,11 @@
"""Handler set for example image routes.""" """Handler set for example image routes."""
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Callable, Mapping from typing import Callable, Mapping
from aiohttp import web from aiohttp import web
logger = logging.getLogger(__name__)
from ...services.use_cases.example_images import ( from ...services.use_cases.example_images import (
DownloadExampleImagesConfigurationError, DownloadExampleImagesConfigurationError,
DownloadExampleImagesInProgressError, DownloadExampleImagesInProgressError,
@@ -95,19 +92,6 @@ class ExampleImagesDownloadHandler:
except ExampleImagesDownloadError as exc: except ExampleImagesDownloadError as exc:
return web.json_response({'success': False, 'error': str(exc)}, status=500) return web.json_response({'success': False, 'error': str(exc)}, status=500)
async def check_example_images_needed(self, request: web.Request) -> web.StreamResponse:
"""Lightweight check to see if any models need example images downloaded."""
try:
payload = await request.json()
model_types = payload.get('model_types', ['lora', 'checkpoint', 'embedding'])
result = await self._download_manager.check_pending_models(model_types)
return web.json_response(result)
except Exception as exc:
return web.json_response(
{'success': False, 'error': str(exc)},
status=500
)
class ExampleImagesManagementHandler: class ExampleImagesManagementHandler:
"""HTTP adapters for import/delete endpoints.""" """HTTP adapters for import/delete endpoints."""
@@ -125,9 +109,6 @@ class ExampleImagesManagementHandler:
return web.json_response({'success': False, 'error': str(exc)}, status=400) return web.json_response({'success': False, 'error': str(exc)}, status=400)
except ExampleImagesImportError as exc: except ExampleImagesImportError as exc:
return web.json_response({'success': False, 'error': str(exc)}, status=500) return web.json_response({'success': False, 'error': str(exc)}, status=500)
except Exception as exc:
logger.exception("Unexpected error importing example images")
return web.json_response({'success': False, 'error': str(exc)}, status=500)
async def delete_example_image(self, request: web.Request) -> web.StreamResponse: async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
return await self._processor.delete_custom_image(request) return await self._processor.delete_custom_image(request)
@@ -180,7 +161,6 @@ class ExampleImagesHandlerSet:
"resume_example_images": self.download.resume_example_images, "resume_example_images": self.download.resume_example_images,
"stop_example_images": self.download.stop_example_images, "stop_example_images": self.download.stop_example_images,
"force_download_example_images": self.download.force_download_example_images, "force_download_example_images": self.download.force_download_example_images,
"check_example_images_needed": self.download.check_example_images_needed,
"import_example_images": self.management.import_example_images, "import_example_images": self.management.import_example_images,
"delete_example_image": self.management.delete_example_image, "delete_example_image": self.management.delete_example_image,
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level, "set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,

View File

@@ -9,7 +9,6 @@ objects that can be composed by the route controller.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import subprocess import subprocess
@@ -193,7 +192,6 @@ class NodeRegistry:
"comfy_class": comfy_class, "comfy_class": comfy_class,
"capabilities": capabilities, "capabilities": capabilities,
"widget_names": widget_names, "widget_names": widget_names,
"mode": node.get("mode"),
} }
logger.debug("Registered %s nodes in registry", len(nodes)) logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set() self._registry_updated.set()
@@ -219,148 +217,45 @@ class HealthCheckHandler:
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok"})
class SupportersHandler:
"""Handler for supporters data."""
def __init__(self, logger: logging.Logger | None = None) -> None:
self._logger = logger or logging.getLogger(__name__)
def _load_supporters(self) -> dict:
"""Load supporters data from JSON file."""
try:
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
supporters_path = os.path.join(root_dir, "data", "supporters.json")
if os.path.exists(supporters_path):
with open(supporters_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
self._logger.debug(f"Failed to load supporters data: {e}")
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
async def get_supporters(self, request: web.Request) -> web.Response:
"""Return supporters data as JSON."""
try:
supporters = self._load_supporters()
return web.json_response({"success": True, "supporters": supporters})
except Exception as exc:
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ExampleWorkflowsHandler:
"""Handler for example workflow templates."""
def __init__(self, logger: logging.Logger | None = None) -> None:
self._logger = logger or logging.getLogger(__name__)
def _get_workflows_dir(self) -> str:
"""Get the example workflows directory path."""
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
return os.path.join(root_dir, "example_workflows")
def _format_workflow_name(self, filename: str) -> str:
"""Convert filename to human-readable name."""
name = os.path.splitext(filename)[0]
name = name.replace("_", " ")
return name
async def get_example_workflows(self, request: web.Request) -> web.Response:
"""Return list of available example workflows."""
try:
workflows_dir = self._get_workflows_dir()
workflows = [
{
"value": "Default",
"label": "Default (Blank)",
"path": None,
}
]
if os.path.exists(workflows_dir):
for filename in sorted(os.listdir(workflows_dir)):
if filename.endswith(".json"):
workflows.append(
{
"value": filename,
"label": self._format_workflow_name(filename),
"path": f"example_workflows/{filename}",
}
)
return web.json_response({"success": True, "workflows": workflows})
except Exception as exc:
self._logger.error(
"Error listing example workflows: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_example_workflow(self, request: web.Request) -> web.Response:
"""Return a specific example workflow JSON content."""
try:
filename = request.match_info.get("filename")
if not filename:
return web.json_response(
{"success": False, "error": "Filename not provided"}, status=400
)
if filename == "Default":
return web.json_response(
{
"success": True,
"workflow": {
"last_node_id": 0,
"last_link_id": 0,
"nodes": [],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
},
}
)
workflows_dir = self._get_workflows_dir()
filepath = os.path.join(workflows_dir, filename)
if not os.path.exists(filepath):
return web.json_response(
{"success": False, "error": f"Workflow not found: {filename}"},
status=404,
)
with open(filepath, "r", encoding="utf-8") as f:
workflow = json.load(f)
return web.json_response({"success": True, "workflow": workflow})
except Exception as exc:
self._logger.error("Error loading example workflow: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class SettingsHandler: class SettingsHandler:
"""Sync settings between backend and frontend.""" """Sync settings between backend and frontend."""
# Settings keys that should NOT be synced to frontend. _SYNC_KEYS = (
# All other settings are synced by default. "civitai_api_key",
_NO_SYNC_KEYS = frozenset( "default_lora_root",
{ "default_checkpoint_root",
# Internal/performance settings (not used by frontend) "default_unet_root",
"hash_chunk_size_mb", "default_embedding_root",
"download_stall_timeout_seconds", "base_model_path_mappings",
# Complex internal structures retrieved via separate endpoints "download_path_templates",
"folder_paths", "enable_metadata_archive_db",
"libraries", "language",
"active_library", "use_portable_settings",
} "onboarding_completed",
"dismissed_banners",
"proxy_enabled",
"proxy_type",
"proxy_host",
"proxy_port",
"proxy_username",
"proxy_password",
"example_images_path",
"optimize_example_images",
"auto_download_example_images",
"blur_mature_content",
"autoplay_on_hover",
"display_density",
"card_info_display",
"show_folder_sidebar",
"include_trigger_words",
"show_only_sfw",
"compact_mode",
"priority_tags",
"model_card_footer_action",
"model_name_display",
"update_flag_strategy",
"auto_organize_exclusions",
"filter_presets",
) )
_PROXY_KEYS = { _PROXY_KEYS = {
@@ -408,9 +303,7 @@ class SettingsHandler:
async def get_settings(self, request: web.Request) -> web.Response: async def get_settings(self, request: web.Request) -> web.Response:
try: try:
response_data = {} response_data = {}
# Sync all settings except those in _NO_SYNC_KEYS for key in self._SYNC_KEYS:
for key in self._settings.keys():
if key not in self._NO_SYNC_KEYS:
value = self._settings.get(key) value = self._settings.get(key)
if value is not None: if value is not None:
response_data[key] = value response_data[key] = value
@@ -1316,7 +1209,6 @@ class CustomWordsHandler:
def __init__(self) -> None: def __init__(self) -> None:
from ...services.custom_words_service import get_custom_words_service from ...services.custom_words_service import get_custom_words_service
self._service = get_custom_words_service() self._service = get_custom_words_service()
async def search_custom_words(self, request: web.Request) -> web.Response: async def search_custom_words(self, request: web.Request) -> web.Response:
@@ -1325,7 +1217,6 @@ class CustomWordsHandler:
Query parameters: Query parameters:
search: The search term to match against. search: The search term to match against.
limit: Maximum number of results to return (default: 20). limit: Maximum number of results to return (default: 20).
offset: Number of results to skip (default: 0).
category: Optional category filter. Can be: category: Optional category filter. Can be:
- A category name (e.g., "character", "artist", "general") - A category name (e.g., "character", "artist", "general")
- Comma-separated category IDs (e.g., "4,11" for character) - Comma-separated category IDs (e.g., "4,11" for character)
@@ -1335,7 +1226,6 @@ class CustomWordsHandler:
try: try:
search_term = request.query.get("search", "") search_term = request.query.get("search", "")
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
offset = max(0, int(request.query.get("offset", "0")))
category_param = request.query.get("category", "") category_param = request.query.get("category", "")
enriched_param = request.query.get("enriched", "").lower() == "true" enriched_param = request.query.get("enriched", "").lower() == "true"
@@ -1345,14 +1235,13 @@ class CustomWordsHandler:
categories = self._parse_category_param(category_param) categories = self._parse_category_param(category_param)
results = self._service.search_words( results = self._service.search_words(
search_term, search_term, limit, categories=categories, enriched=enriched_param
limit,
offset=offset,
categories=categories,
enriched=enriched_param,
) )
return web.json_response({"success": True, "words": results}) return web.json_response({
"success": True,
"words": results
})
except Exception as exc: except Exception as exc:
logger.error("Error searching custom words: %s", exc, exc_info=True) logger.error("Error searching custom words: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
@@ -1616,8 +1505,6 @@ class MiscHandlerSet:
metadata_archive: MetadataArchiveHandler, metadata_archive: MetadataArchiveHandler,
filesystem: FileSystemHandler, filesystem: FileSystemHandler,
custom_words: CustomWordsHandler, custom_words: CustomWordsHandler,
supporters: SupportersHandler,
example_workflows: ExampleWorkflowsHandler,
) -> None: ) -> None:
self.health = health self.health = health
self.settings = settings self.settings = settings
@@ -1630,8 +1517,6 @@ class MiscHandlerSet:
self.metadata_archive = metadata_archive self.metadata_archive = metadata_archive
self.filesystem = filesystem self.filesystem = filesystem
self.custom_words = custom_words self.custom_words = custom_words
self.supporters = supporters
self.example_workflows = example_workflows
def to_route_mapping( def to_route_mapping(
self, self,
@@ -1660,9 +1545,6 @@ class MiscHandlerSet:
"open_file_location": self.filesystem.open_file_location, "open_file_location": self.filesystem.open_file_location,
"open_settings_location": self.filesystem.open_settings_location, "open_settings_location": self.filesystem.open_settings_location,
"search_custom_words": self.custom_words.search_custom_words, "search_custom_words": self.custom_words.search_custom_words,
"get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow,
} }

View File

@@ -6,7 +6,6 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
@@ -66,23 +65,6 @@ class ModelPageView:
self._logger = logger self._logger = logger
self._app_version = self._get_app_version() self._app_version = self._get_app_version()
def _load_supporters(self) -> dict:
"""Load supporters data from JSON file."""
try:
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
supporters_path = os.path.join(root_dir, "data", "supporters.json")
if os.path.exists(supporters_path):
with open(supporters_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
self._logger.debug(f"Failed to load supporters data: {e}")
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
def _get_app_version(self) -> str: def _get_app_version(self) -> str:
version = "1.0.0" version = "1.0.0"
short_hash = "stable" short_hash = "stable"
@@ -287,11 +269,6 @@ class ModelListingHandler:
request.query.get("update_available_only", "false").lower() == "true" request.query.get("update_available_only", "false").lower() == "true"
) )
# Tag logic: "any" (OR) or "all" (AND) for include tags
tag_logic = request.query.get("tag_logic", "any").lower()
if tag_logic not in ("any", "all"):
tag_logic = "any"
# New license-based query filters # New license-based query filters
credit_required = request.query.get("credit_required") credit_required = request.query.get("credit_required")
if credit_required is not None: if credit_required is not None:
@@ -309,13 +286,6 @@ class ModelListingHandler:
else: else:
allow_selling_generated_content = None # None means no filter applied allow_selling_generated_content = None # None means no filter applied
# Name pattern filters for LoRA Pool
name_pattern_include = request.query.getall("name_pattern_include", [])
name_pattern_exclude = request.query.getall("name_pattern_exclude", [])
name_pattern_use_regex = (
request.query.get("name_pattern_use_regex", "false").lower() == "true"
)
return { return {
"page": page, "page": page,
"page_size": page_size, "page_size": page_size,
@@ -327,7 +297,6 @@ class ModelListingHandler:
"fuzzy_search": fuzzy_search, "fuzzy_search": fuzzy_search,
"base_models": base_models, "base_models": base_models,
"tags": tag_filters, "tags": tag_filters,
"tag_logic": tag_logic,
"search_options": search_options, "search_options": search_options,
"hash_filters": hash_filters, "hash_filters": hash_filters,
"favorites_only": favorites_only, "favorites_only": favorites_only,
@@ -335,9 +304,6 @@ class ModelListingHandler:
"credit_required": credit_required, "credit_required": credit_required,
"allow_selling_generated_content": allow_selling_generated_content, "allow_selling_generated_content": allow_selling_generated_content,
"model_types": model_types, "model_types": model_types,
"name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex,
**self._parse_specific_params(request), **self._parse_specific_params(request),
} }
@@ -410,31 +376,7 @@ class ModelManagementHandler:
return web.json_response( return web.json_response(
{"success": False, "error": "Model not found in cache"}, status=404 {"success": False, "error": "Model not found in cache"}, status=404
) )
if not model_data.get("sha256"):
# Check if hash needs to be calculated (lazy hash for checkpoints)
sha256 = model_data.get("sha256")
hash_status = model_data.get("hash_status", "completed")
if not sha256 or hash_status != "completed":
# For checkpoints, calculate hash on-demand
scanner = self._service.scanner
if hasattr(scanner, "calculate_hash_for_model"):
self._logger.info(
f"Lazy hash calculation triggered for {file_path}"
)
sha256 = await scanner.calculate_hash_for_model(file_path)
if not sha256:
return web.json_response(
{
"success": False,
"error": "Failed to calculate SHA256 hash",
},
status=500,
)
# Update model_data with new hash
model_data["sha256"] = sha256
model_data["hash_status"] = "completed"
else:
return web.json_response( return web.json_response(
{"success": False, "error": "No SHA256 hash found"}, status=400 {"success": False, "error": "No SHA256 hash found"}, status=400
) )
@@ -557,153 +499,6 @@ class ModelManagementHandler:
self._logger.error("Error replacing preview: %s", exc, exc_info=True) self._logger.error("Error replacing preview: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)
async def set_preview_from_url(self, request: web.Request) -> web.Response:
"""Set a preview image from a remote URL (e.g., CivitAI)."""
try:
from ...utils.civitai_utils import rewrite_preview_url
from ...services.downloader import get_downloader
data = await request.json()
model_path = data.get("model_path")
image_url = data.get("image_url")
nsfw_level = data.get("nsfw_level", 0)
if not model_path:
return web.json_response(
{"success": False, "error": "Model path is required"}, status=400
)
if not image_url:
return web.json_response(
{"success": False, "error": "Image URL is required"}, status=400
)
# Rewrite URL to use optimized rendition if it's a Civitai URL
optimized_url, was_rewritten = rewrite_preview_url(
image_url, media_type="image"
)
if was_rewritten and optimized_url:
self._logger.info(
f"Rewritten preview URL to optimized version: {optimized_url}"
)
else:
optimized_url = image_url
# Download the image using the Downloader service
self._logger.info(
f"Downloading preview from {optimized_url} for {model_path}"
)
downloader = await get_downloader()
success, preview_data, headers = await downloader.download_to_memory(
optimized_url, use_auth=False, return_headers=True
)
if not success:
return web.json_response(
{
"success": False,
"error": f"Failed to download image: {preview_data}",
},
status=502,
)
# preview_data is bytes when success is True
preview_bytes = (
preview_data
if isinstance(preview_data, bytes)
else preview_data.encode("utf-8")
)
# Determine content type from response headers
content_type = (
headers.get("Content-Type", "image/jpeg") if headers else "image/jpeg"
)
# Extract original filename from URL
original_filename = None
if "?" in image_url:
url_path = image_url.split("?")[0]
else:
url_path = image_url
original_filename = url_path.split("/")[-1] if "/" in url_path else None
result = await self._preview_service.replace_preview(
model_path=model_path,
preview_data=preview_data,
content_type=content_type,
original_filename=original_filename,
nsfw_level=nsfw_level,
update_preview_in_cache=self._service.scanner.update_preview_in_cache,
metadata_loader=self._metadata_sync.load_local_metadata,
)
return web.json_response(
{
"success": True,
"preview_url": config.get_preview_static_url(
result["preview_path"]
),
"preview_nsfw_level": result["preview_nsfw_level"],
}
)
except Exception as exc:
self._logger.error("Error setting preview from URL: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
if not image_url:
return web.json_response(
{"success": False, "error": "Image URL is required"}, status=400
)
# Download the image from the remote URL
self._logger.info(f"Downloading preview from {image_url} for {model_path}")
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status != 200:
return web.json_response(
{
"success": False,
"error": f"Failed to download image: HTTP {response.status}",
},
status=502,
)
content_type = response.headers.get("Content-Type", "image/jpeg")
preview_data = await response.read()
# Extract original filename from URL
original_filename = None
if "?" in image_url:
url_path = image_url.split("?")[0]
else:
url_path = image_url
original_filename = (
url_path.split("/")[-1] if "/" in url_path else None
)
result = await self._preview_service.replace_preview(
model_path=model_path,
preview_data=preview_bytes,
content_type=content_type,
original_filename=original_filename,
nsfw_level=nsfw_level,
update_preview_in_cache=self._service.scanner.update_preview_in_cache,
metadata_loader=self._metadata_sync.load_local_metadata,
)
return web.json_response(
{
"success": True,
"preview_url": config.get_preview_static_url(
result["preview_path"]
),
"preview_nsfw_level": result["preview_nsfw_level"],
}
)
except Exception as exc:
self._logger.error("Error setting preview from URL: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def save_metadata(self, request: web.Request) -> web.Response: async def save_metadata(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -846,7 +641,7 @@ class ModelQueryHandler:
async def get_top_tags(self, request: web.Request) -> web.Response: async def get_top_tags(self, request: web.Request) -> web.Response:
try: try:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
if limit < 0: if limit < 1 or limit > 100:
limit = 20 limit = 20
top_tags = await self._service.get_top_tags(limit) top_tags = await self._service.get_top_tags(limit)
return web.json_response({"success": True, "tags": top_tags}) return web.json_response({"success": True, "tags": top_tags})
@@ -960,22 +755,19 @@ class ModelQueryHandler:
async def find_duplicate_models(self, request: web.Request) -> web.Response: async def find_duplicate_models(self, request: web.Request) -> web.Response:
try: try:
filters = self._parse_duplicate_filters(request)
duplicates = self._service.find_duplicate_hashes() duplicates = self._service.find_duplicate_hashes()
result = [] result = []
cache = await self._service.scanner.get_cached_data() cache = await self._service.scanner.get_cached_data()
for sha256, paths in duplicates.items(): for sha256, paths in duplicates.items():
# Collect all models in this group group = {"hash": sha256, "models": []}
all_models = []
for path in paths: for path in paths:
model = next( model = next(
(m for m in cache.raw_data if m["file_path"] == path), None (m for m in cache.raw_data if m["file_path"] == path), None
) )
if model: if model:
all_models.append(model) group["models"].append(
await self._service.format_response(model)
# Include primary if not already in paths )
primary_path = self._service.get_path_by_hash(sha256) primary_path = self._service.get_path_by_hash(sha256)
if primary_path and primary_path not in paths: if primary_path and primary_path not in paths:
primary_model = next( primary_model = next(
@@ -983,23 +775,11 @@ class ModelQueryHandler:
None, None,
) )
if primary_model: if primary_model:
all_models.insert(0, primary_model) group["models"].insert(
0, await self._service.format_response(primary_model)
# Apply filters )
filtered = self._apply_duplicate_filters(all_models, filters)
# Sort: originals first, copies last
sorted_models = self._sort_duplicate_group(filtered)
# Format response
group = {"hash": sha256, "models": []}
for model in sorted_models:
group["models"].append(await self._service.format_response(model))
# Only include groups with 2+ models after filtering
if len(group["models"]) > 1: if len(group["models"]) > 1:
result.append(group) result.append(group)
return web.json_response( return web.json_response(
{"success": True, "duplicates": result, "count": len(result)} {"success": True, "duplicates": result, "count": len(result)}
) )
@@ -1012,87 +792,6 @@ class ModelQueryHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
def _parse_duplicate_filters(self, request: web.Request) -> Dict[str, Any]:
"""Parse filter parameters from the request for duplicate finding."""
return {
"base_models": request.query.getall("base_model", []),
"tag_include": request.query.getall("tag_include", []),
"tag_exclude": request.query.getall("tag_exclude", []),
"model_types": request.query.getall("model_type", []),
"folder": request.query.get("folder"),
"favorites_only": request.query.get("favorites_only", "").lower() == "true",
}
def _apply_duplicate_filters(
self, models: List[Dict[str, Any]], filters: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Apply filters to a list of models within a duplicate group."""
result = models
# Apply base model filter
if filters.get("base_models"):
base_set = set(filters["base_models"])
result = [m for m in result if m.get("base_model") in base_set]
# Apply tag filters (include)
for tag in filters.get("tag_include", []):
if tag == "__no_tags__":
result = [m for m in result if not m.get("tags")]
else:
result = [m for m in result if tag in (m.get("tags") or [])]
# Apply tag filters (exclude)
for tag in filters.get("tag_exclude", []):
if tag == "__no_tags__":
result = [m for m in result if m.get("tags")]
else:
result = [m for m in result if tag not in (m.get("tags") or [])]
# Apply model type filter
if filters.get("model_types"):
type_set = {t.lower() for t in filters["model_types"]}
result = [
m for m in result if (m.get("model_type") or "").lower() in type_set
]
# Apply folder filter
if filters.get("folder"):
folder = filters["folder"]
result = [m for m in result if m.get("folder", "").startswith(folder)]
# Apply favorites filter
if filters.get("favorites_only"):
result = [m for m in result if m.get("favorite", False)]
return result
def _sort_duplicate_group(
self, models: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Sort models: originals first (left), copies (with -????. pattern) last (right)."""
if len(models) <= 1:
return models
min_len = min(len(m.get("file_name", "")) for m in models)
def copy_score(m):
fn = m.get("file_name", "")
score = 0
# Match -0001.safetensors, -1234.safetensors etc.
if re.search(r"-\d{4}\.", fn):
score += 100
# Match (1), (2) etc.
if re.search(r"\(\d+\)", fn):
score += 50
# Match 'copy' in filename
if "copy" in fn.lower():
score += 50
# Longer filenames are more likely copies
score += len(fn) - min_len
return (score, fn.lower())
return sorted(models, key=copy_score)
async def find_filename_conflicts(self, request: web.Request) -> web.Response: async def find_filename_conflicts(self, request: web.Request) -> web.Response:
try: try:
duplicates = self._service.find_duplicate_filenames() duplicates = self._service.find_duplicate_filenames()
@@ -1278,11 +977,8 @@ class ModelQueryHandler:
async def get_relative_paths(self, request: web.Request) -> web.Response: async def get_relative_paths(self, request: web.Request) -> web.Response:
try: try:
search = request.query.get("search", "").strip() search = request.query.get("search", "").strip()
limit = min(int(request.query.get("limit", "15")), 100) limit = min(int(request.query.get("limit", "15")), 50)
offset = max(0, int(request.query.get("offset", "0"))) matching_paths = await self._service.search_relative_paths(search, limit)
matching_paths = await self._service.search_relative_paths(
search, limit, offset
)
return web.json_response( return web.json_response(
{"success": True, "relative_paths": matching_paths} {"success": True, "relative_paths": matching_paths}
) )
@@ -1345,7 +1041,6 @@ class ModelDownloadHandler:
request.query.get("use_default_paths", "false").lower() == "true" request.query.get("use_default_paths", "false").lower() == "true"
) )
source = request.query.get("source") source = request.query.get("source")
file_params_json = request.query.get("file_params")
data = {"model_id": model_id, "use_default_paths": use_default_paths} data = {"model_id": model_id, "use_default_paths": use_default_paths}
if model_version_id: if model_version_id:
@@ -1354,15 +1049,6 @@ class ModelDownloadHandler:
data["download_id"] = download_id data["download_id"] = download_id
if source: if source:
data["source"] = source data["source"] = source
if file_params_json:
import json
try:
data["file_params"] = json.loads(file_params_json)
except json.JSONDecodeError:
self._logger.warning(
"Invalid file_params JSON: %s", file_params_json
)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
future = loop.create_future() future = loop.create_future()
@@ -1746,13 +1432,11 @@ class ModelUpdateHandler:
service, service,
update_service, update_service,
metadata_provider_selector, metadata_provider_selector,
settings_service,
logger: logging.Logger, logger: logging.Logger,
) -> None: ) -> None:
self._service = service self._service = service
self._update_service = update_service self._update_service = update_service
self._metadata_provider_selector = metadata_provider_selector self._metadata_provider_selector = metadata_provider_selector
self._settings = settings_service
self._logger = logger self._logger = logger
async def fetch_missing_civitai_license_data( async def fetch_missing_civitai_license_data(
@@ -1989,9 +1673,6 @@ class ModelUpdateHandler:
{"success": False, "error": "Model not tracked"}, status=404 {"success": False, "error": "Model not tracked"}, status=404
) )
# Enrich EA versions with detailed info if needed
record = await self._enrich_early_access_details(record)
overrides = await self._build_version_context(record) overrides = await self._build_version_context(record)
return web.json_response( return web.json_response(
{ {
@@ -2030,79 +1711,6 @@ class ModelUpdateHandler:
) )
return None return None
async def _enrich_early_access_details(self, record):
"""Fetch detailed EA info for versions missing exact end time.
Identifies versions with is_early_access=True but no early_access_ends_at,
then fetches detailed info from CivitAI to get the exact end time.
"""
if not record or not record.versions:
return record
# Find versions that need enrichment
versions_needing_update = []
for version in record.versions:
if version.is_early_access and not version.early_access_ends_at:
versions_needing_update.append(version)
if not versions_needing_update:
return record
provider = await self._get_civitai_provider()
if not provider:
return record
# Fetch detailed info for each version needing update
updated_versions = []
for version in versions_needing_update:
try:
version_info, error = await provider.get_model_version_info(
str(version.version_id)
)
if version_info and not error:
ea_ends_at = version_info.get("earlyAccessEndsAt")
if ea_ends_at:
# Create updated version with EA end time
from dataclasses import replace
updated_version = replace(
version, early_access_ends_at=ea_ends_at
)
updated_versions.append(updated_version)
self._logger.debug(
"Enriched EA info for version %s: %s",
version.version_id,
ea_ends_at,
)
except Exception as exc:
self._logger.debug(
"Failed to fetch EA details for version %s: %s",
version.version_id,
exc,
)
if not updated_versions:
return record
# Update record with enriched versions
version_map = {v.version_id: v for v in record.versions}
for updated in updated_versions:
version_map[updated.version_id] = updated
# Create new record with updated versions
from dataclasses import replace
new_record = replace(
record,
versions=list(version_map.values()),
)
# Optionally persist to database for caching
# Note: We don't persist here to avoid side effects; the data will be
# refreshed on next bulk update if still needed
return new_record
async def _collect_models_missing_license( async def _collect_models_missing_license(
self, self,
cache, cache,
@@ -2269,15 +1877,6 @@ class ModelUpdateHandler:
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None, version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
) -> Dict: ) -> Dict:
context = version_context or {} context = version_context or {}
# Check user setting for hiding early access versions
hide_early_access = False
if self._settings is not None:
try:
hide_early_access = bool(
self._settings.get("hide_early_access_updates", False)
)
except Exception:
pass
return { return {
"modelType": record.model_type, "modelType": record.model_type,
"modelId": record.model_id, "modelId": record.model_id,
@@ -2286,7 +1885,7 @@ class ModelUpdateHandler:
"inLibraryVersionIds": record.in_library_version_ids, "inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at, "lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model, "shouldIgnore": record.should_ignore_model,
"hasUpdate": record.has_update(hide_early_access=hide_early_access), "hasUpdate": record.has_update(),
"versions": [ "versions": [
self._serialize_version(version, context.get(version.version_id)) self._serialize_version(version, context.get(version.version_id))
for version in record.versions for version in record.versions
@@ -2302,25 +1901,6 @@ class ModelUpdateHandler:
preview_url = ( preview_url = (
preview_override if preview_override is not None else version.preview_url preview_override if preview_override is not None else version.preview_url
) )
# Determine if version is currently in early access
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag
is_early_access = False
if version.early_access_ends_at:
try:
from datetime import datetime, timezone
ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00")
)
is_early_access = ea_date > datetime.now(timezone.utc)
except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative)
is_early_access = True
elif getattr(version, "is_early_access", False):
# Fallback to basic EA flag from bulk API
is_early_access = True
return { return {
"versionId": version.version_id, "versionId": version.version_id,
"name": version.name, "name": version.name,
@@ -2330,8 +1910,6 @@ class ModelUpdateHandler:
"previewUrl": preview_url, "previewUrl": preview_url,
"isInLibrary": version.is_in_library, "isInLibrary": version.is_in_library,
"shouldIgnore": version.should_ignore, "shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access,
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
} }
@@ -2397,7 +1975,6 @@ class ModelHandlerSet:
"fetch_all_civitai": self.civitai.fetch_all_civitai, "fetch_all_civitai": self.civitai.fetch_all_civitai,
"relink_civitai": self.management.relink_civitai, "relink_civitai": self.management.relink_civitai,
"replace_preview": self.management.replace_preview, "replace_preview": self.management.replace_preview,
"set_preview_from_url": self.management.set_preview_from_url,
"save_metadata": self.management.save_metadata, "save_metadata": self.management.save_metadata,
"add_tags": self.management.add_tags, "add_tags": self.management.add_tags,
"rename_model": self.management.rename_model, "rename_model": self.management.rename_model,

View File

@@ -33,10 +33,6 @@ class PreviewHandler:
raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc raise web.HTTPBadRequest(text="Invalid preview path encoding") from exc
normalized = decoded_path.replace("\\", "/") normalized = decoded_path.replace("\\", "/")
if not self._config.is_preview_path_allowed(normalized):
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
candidate = Path(normalized) candidate = Path(normalized)
try: try:
resolved = candidate.expanduser().resolve(strict=False) resolved = candidate.expanduser().resolve(strict=False)
@@ -44,8 +40,12 @@ class PreviewHandler:
logger.debug("Failed to resolve preview path %s: %s", normalized, exc) logger.debug("Failed to resolve preview path %s: %s", normalized, exc)
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
resolved_str = str(resolved)
if not self._config.is_preview_path_allowed(resolved_str):
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
if not resolved.is_file(): if not resolved.is_file():
logger.debug("Preview file not found at %s", str(resolved)) logger.debug("Preview file not found at %s", resolved_str)
raise web.HTTPNotFound(text="Preview file not found") raise web.HTTPNotFound(text="Preview file not found")
# aiohttp's FileResponse handles range requests and content headers for us. # aiohttp's FileResponse handles range requests and content headers for us.

View File

@@ -1,5 +1,4 @@
"""Dedicated handler objects for recipe-related routes.""" """Dedicated handler objects for recipe-related routes."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -9,7 +8,6 @@ import re
import asyncio import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
from aiohttp import web from aiohttp import web
@@ -31,7 +29,6 @@ from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager from ...services.websocket_manager import ws_manager as default_ws_manager
from ...services.batch_import_service import BatchImportService
Logger = logging.Logger Logger = logging.Logger
EnsureDependenciesCallable = Callable[[], Awaitable[None]] EnsureDependenciesCallable = Callable[[], Awaitable[None]]
@@ -49,11 +46,8 @@ class RecipeHandlerSet:
management: "RecipeManagementHandler" management: "RecipeManagementHandler"
analysis: "RecipeAnalysisHandler" analysis: "RecipeAnalysisHandler"
sharing: "RecipeSharingHandler" sharing: "RecipeSharingHandler"
batch_import: "BatchImportHandler"
def to_route_mapping( def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
"""Expose handler coroutines keyed by registrar handler names.""" """Expose handler coroutines keyed by registrar handler names."""
return { return {
@@ -87,11 +81,6 @@ class RecipeHandlerSet:
"cancel_repair": self.management.cancel_repair, "cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe, "repair_recipe": self.management.repair_recipe,
"get_repair_progress": self.management.get_repair_progress, "get_repair_progress": self.management.get_repair_progress,
"start_batch_import": self.batch_import.start_batch_import,
"get_batch_import_progress": self.batch_import.get_batch_import_progress,
"cancel_batch_import": self.batch_import.cancel_batch_import,
"start_directory_import": self.batch_import.start_directory_import,
"browse_directory": self.batch_import.browse_directory,
} }
@@ -181,10 +170,8 @@ class RecipeListingHandler:
search_options = { search_options = {
"title": request.query.get("search_title", "true").lower() == "true", "title": request.query.get("search_title", "true").lower() == "true",
"tags": request.query.get("search_tags", "true").lower() == "true", "tags": request.query.get("search_tags", "true").lower() == "true",
"lora_name": request.query.get("search_lora_name", "true").lower() "lora_name": request.query.get("search_lora_name", "true").lower() == "true",
== "true", "lora_model": request.query.get("search_lora_model", "true").lower() == "true",
"lora_model": request.query.get("search_lora_model", "true").lower()
== "true",
"prompt": request.query.get("search_prompt", "true").lower() == "true", "prompt": request.query.get("search_prompt", "true").lower() == "true",
} }
@@ -259,9 +246,7 @@ class RecipeListingHandler:
return web.json_response({"error": "Recipe not found"}, status=404) return web.json_response({"error": "Recipe not found"}, status=404)
return web.json_response(recipe) return web.json_response(recipe)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error retrieving recipe details: %s", exc, exc_info=True)
"Error retrieving recipe details: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
def format_recipe_file_url(self, file_path: str) -> str: def format_recipe_file_url(self, file_path: str) -> str:
@@ -271,9 +256,7 @@ class RecipeListingHandler:
if static_url: if static_url:
return static_url return static_url
except Exception as exc: # pragma: no cover - logging path except Exception as exc: # pragma: no cover - logging path
self._logger.error( self._logger.error("Error formatting recipe file URL: %s", exc, exc_info=True)
"Error formatting recipe file URL: %s", exc, exc_info=True
)
return "/loras_static/images/no-preview.png" return "/loras_static/images/no-preview.png"
return "/loras_static/images/no-preview.png" return "/loras_static/images/no-preview.png"
@@ -310,9 +293,7 @@ class RecipeQueryHandler:
for tag in recipe.get("tags", []) or []: for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1 tag_counts[tag] = tag_counts.get(tag, 0) + 1
sorted_tags = [ sorted_tags = [{"tag": tag, "count": count} for tag, count in tag_counts.items()]
{"tag": tag, "count": count} for tag, count in tag_counts.items()
]
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True) sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "tags": sorted_tags[:limit]}) return web.json_response({"success": True, "tags": sorted_tags[:limit]})
except Exception as exc: except Exception as exc:
@@ -332,14 +313,9 @@ class RecipeQueryHandler:
for recipe in getattr(cache, "raw_data", []): for recipe in getattr(cache, "raw_data", []):
base_model = recipe.get("base_model") base_model = recipe.get("base_model")
if base_model: if base_model:
base_model_counts[base_model] = ( base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
base_model_counts.get(base_model, 0) + 1
)
sorted_models = [ sorted_models = [{"name": model, "count": count} for model, count in base_model_counts.items()]
{"name": model, "count": count}
for model, count in base_model_counts.items()
]
sorted_models.sort(key=lambda entry: entry["count"], reverse=True) sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response({"success": True, "base_models": sorted_models}) return web.json_response({"success": True, "base_models": sorted_models})
except Exception as exc: except Exception as exc:
@@ -369,9 +345,7 @@ class RecipeQueryHandler:
folders = await recipe_scanner.get_folders() folders = await recipe_scanner.get_folders()
return web.json_response({"success": True, "folders": folders}) return web.json_response({"success": True, "folders": folders})
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
"Error retrieving recipe folders: %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 get_folder_tree(self, request: web.Request) -> web.Response: async def get_folder_tree(self, request: web.Request) -> web.Response:
@@ -384,9 +358,7 @@ class RecipeQueryHandler:
folder_tree = await recipe_scanner.get_folder_tree() folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree}) return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
"Error retrieving recipe folder tree: %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 get_unified_folder_tree(self, request: web.Request) -> web.Response: async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
@@ -399,9 +371,7 @@ class RecipeQueryHandler:
folder_tree = await recipe_scanner.get_folder_tree() folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree}) return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
"Error retrieving unified recipe folder tree: %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 get_recipes_for_lora(self, request: web.Request) -> web.Response: async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
@@ -413,9 +383,7 @@ class RecipeQueryHandler:
lora_hash = request.query.get("hash") lora_hash = request.query.get("hash")
if not lora_hash: if not lora_hash:
return web.json_response( return web.json_response({"success": False, "error": "Lora hash is required"}, status=400)
{"success": False, "error": "Lora hash is required"}, status=400
)
matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash) matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash)
return web.json_response({"success": True, "recipes": matching_recipes}) return web.json_response({"success": True, "recipes": matching_recipes})
@@ -432,9 +400,7 @@ class RecipeQueryHandler:
self._logger.info("Manually triggering recipe cache rebuild") self._logger.info("Manually triggering recipe cache rebuild")
await recipe_scanner.get_cached_data(force_refresh=True) await recipe_scanner.get_cached_data(force_refresh=True)
return web.json_response( return web.json_response({"success": True, "message": "Recipe cache refreshed successfully"})
{"success": True, "message": "Recipe cache refreshed successfully"}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True) self._logger.error("Error refreshing recipe cache: %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)
@@ -446,11 +412,10 @@ class RecipeQueryHandler:
if recipe_scanner is None: if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
fingerprint_groups = await recipe_scanner.find_all_duplicate_recipes() duplicate_groups = await recipe_scanner.find_all_duplicate_recipes()
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
response_data = [] response_data = []
for fingerprint, recipe_ids in fingerprint_groups.items(): for fingerprint, recipe_ids in duplicate_groups.items():
if len(recipe_ids) <= 1: if len(recipe_ids) <= 1:
continue continue
@@ -463,9 +428,7 @@ class RecipeQueryHandler:
"id": recipe.get("id"), "id": recipe.get("id"),
"title": recipe.get("title"), "title": recipe.get("title"),
"file_url": recipe.get("file_url") "file_url": recipe.get("file_url")
or self._format_recipe_file_url( or self._format_recipe_file_url(recipe.get("file_path", "")),
recipe.get("file_path", "")
),
"modified": recipe.get("modified"), "modified": recipe.get("modified"),
"created_date": recipe.get("created_date"), "created_date": recipe.get("created_date"),
"lora_count": len(recipe.get("loras", [])), "lora_count": len(recipe.get("loras", [])),
@@ -473,61 +436,19 @@ class RecipeQueryHandler:
) )
if len(recipes) >= 2: if len(recipes) >= 2:
recipes.sort( recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
key=lambda entry: entry.get("modified", 0), reverse=True
)
response_data.append( response_data.append(
{ {
"type": "fingerprint",
"fingerprint": fingerprint, "fingerprint": fingerprint,
"count": len(recipes), "count": len(recipes),
"recipes": recipes, "recipes": recipes,
} }
) )
for url, recipe_ids in url_groups.items():
if len(recipe_ids) <= 1:
continue
recipes = []
for recipe_id in recipe_ids:
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
if recipe:
recipes.append(
{
"id": recipe.get("id"),
"title": recipe.get("title"),
"file_url": recipe.get("file_url")
or self._format_recipe_file_url(
recipe.get("file_path", "")
),
"modified": recipe.get("modified"),
"created_date": recipe.get("created_date"),
"lora_count": len(recipe.get("loras", [])),
}
)
if len(recipes) >= 2:
recipes.sort(
key=lambda entry: entry.get("modified", 0), reverse=True
)
response_data.append(
{
"type": "source_url",
"fingerprint": url,
"count": len(recipes),
"recipes": recipes,
}
)
response_data.sort(key=lambda entry: entry["count"], reverse=True) response_data.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response( return web.json_response({"success": True, "duplicate_groups": response_data})
{"success": True, "duplicate_groups": response_data}
)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error finding duplicate recipes: %s", exc, exc_info=True)
"Error finding duplicate recipes: %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 get_recipe_syntax(self, request: web.Request) -> web.Response: async def get_recipe_syntax(self, request: web.Request) -> web.Response:
@@ -544,13 +465,9 @@ class RecipeQueryHandler:
return web.json_response({"error": "Recipe not found"}, status=404) return web.json_response({"error": "Recipe not found"}, status=404)
if not syntax_parts: if not syntax_parts:
return web.json_response( return web.json_response({"error": "No LoRAs found in this recipe"}, status=400)
{"error": "No LoRAs found in this recipe"}, status=400
)
return web.json_response( return web.json_response({"success": True, "syntax": " ".join(syntax_parts)})
{"success": True, "syntax": " ".join(syntax_parts)}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True) self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
@@ -611,17 +528,11 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
return web.json_response( return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
# Check if already running # Check if already running
if self._ws_manager.is_recipe_repair_running(): if self._ws_manager.is_recipe_repair_running():
return web.json_response( return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
{"success": False, "error": "Recipe repair already in progress"},
status=409,
)
recipe_scanner.reset_cancellation() recipe_scanner.reset_cancellation()
@@ -635,12 +546,11 @@ class RecipeManagementHandler:
progress_callback=progress_callback progress_callback=progress_callback
) )
except Exception as e: except Exception as e:
self._logger.error( self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
f"Error in recipe repair task: {e}", exc_info=True await self._ws_manager.broadcast_recipe_repair_progress({
) "status": "error",
await self._ws_manager.broadcast_recipe_repair_progress( "error": str(e)
{"status": "error", "error": str(e)} })
)
finally: finally:
# Keep the final status for a while so the UI can see it # Keep the final status for a while so the UI can see it
await asyncio.sleep(5) await asyncio.sleep(5)
@@ -650,9 +560,7 @@ class RecipeManagementHandler:
asyncio.create_task(run_repair()) asyncio.create_task(run_repair())
return web.json_response( return web.json_response({"success": True, "message": "Recipe repair started"})
{"success": True, "message": "Recipe repair started"}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True) self._logger.error("Error starting recipe repair: %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)
@@ -662,15 +570,10 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
return web.json_response( return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_scanner.cancel_task() recipe_scanner.cancel_task()
return web.json_response( return web.json_response({"success": True, "message": "Cancellation requested"})
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc: except Exception as exc:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True) self._logger.error("Error cancelling recipe repair: %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)
@@ -680,10 +583,7 @@ class RecipeManagementHandler:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
return web.json_response( return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
{"success": False, "error": "Recipe scanner unavailable"},
status=503,
)
recipe_id = request.match_info["recipe_id"] recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.repair_recipe_by_id(recipe_id) result = await recipe_scanner.repair_recipe_by_id(recipe_id)
@@ -699,13 +599,12 @@ class RecipeManagementHandler:
progress = self._ws_manager.get_recipe_repair_progress() progress = self._ws_manager.get_recipe_repair_progress()
if progress: if progress:
return web.json_response({"success": True, "progress": progress}) return web.json_response({"success": True, "progress": progress})
return web.json_response( return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
{"success": False, "message": "No repair in progress"}, status=404
)
except Exception as exc: except Exception as exc:
self._logger.error("Error getting repair progress: %s", exc, exc_info=True) self._logger.error("Error getting repair progress: %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 import_remote_recipe(self, request: web.Request) -> web.Response: async def import_remote_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -726,9 +625,7 @@ class RecipeManagementHandler:
if not resources_raw: if not resources_raw:
raise RecipeValidationError("Missing required field: resources") raise RecipeValidationError("Missing required field: resources")
checkpoint_entry, lora_entries = self._parse_resources_payload( checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
resources_raw
)
gen_params_request = self._parse_gen_params(params.get("gen_params")) gen_params_request = self._parse_gen_params(params.get("gen_params"))
# 2. Initial Metadata Construction # 2. Initial Metadata Construction
@@ -736,7 +633,7 @@ class RecipeManagementHandler:
"base_model": params.get("base_model", "") or "", "base_model": params.get("base_model", "") or "",
"loras": lora_entries, "loras": lora_entries,
"gen_params": gen_params_request or {}, "gen_params": gen_params_request or {},
"source_url": image_url, "source_url": image_url
} }
source_path = params.get("source_path") source_path = params.get("source_path")
@@ -751,20 +648,14 @@ class RecipeManagementHandler:
# Try to resolve base model from checkpoint if not explicitly provided # Try to resolve base model from checkpoint if not explicitly provided
if not metadata["base_model"]: if not metadata["base_model"]:
base_model_from_metadata = ( base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
)
if base_model_from_metadata: if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags")) tags = self._parse_tags(params.get("tags"))
# 3. Download Image # 3. Download Image
( image_bytes, extension, civitai_meta_from_download = await self._download_remote_media(image_url)
image_bytes,
extension,
civitai_meta_from_download,
) = await self._download_remote_media(image_url)
# 4. Extract Embedded Metadata # 4. Extract Embedded Metadata
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated # Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
@@ -782,24 +673,16 @@ class RecipeManagementHandler:
# Let's extract embedded metadata first # Let's extract embedded metadata first
embedded_gen_params = {} embedded_gen_params = {}
try: try:
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
suffix=extension, delete=False
) as temp_img:
temp_img.write(image_bytes) temp_img.write(image_bytes)
temp_img_path = temp_img.name temp_img_path = temp_img.name
try: try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path) raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
if raw_embedded: if raw_embedded:
parser = ( parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
self._analysis_service._recipe_parser_factory.create_parser(
raw_embedded
)
)
if parser: if parser:
parsed_embedded = await parser.parse_metadata( parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
raw_embedded, recipe_scanner=recipe_scanner
)
if parsed_embedded and "gen_params" in parsed_embedded: if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"] embedded_gen_params = parsed_embedded["gen_params"]
else: else:
@@ -808,9 +691,7 @@ class RecipeManagementHandler:
if os.path.exists(temp_img_path): if os.path.exists(temp_img_path):
os.unlink(temp_img_path) os.unlink(temp_img_path)
except Exception as exc: except Exception as exc:
self._logger.warning( self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
"Failed to extract embedded metadata during import: %s", exc
)
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer # Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
if embedded_gen_params: if embedded_gen_params:
@@ -825,7 +706,7 @@ class RecipeManagementHandler:
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
recipe=metadata, recipe=metadata,
civitai_client=civitai_client, civitai_client=civitai_client,
request_params=gen_params_request, # Pass explicit request params here to override request_params=gen_params_request # Pass explicit request params here to override
) )
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed), # If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
@@ -848,9 +729,7 @@ class RecipeManagementHandler:
except RecipeDownloadError as exc: except RecipeDownloadError as exc:
return web.json_response({"error": str(exc)}, status=400) return web.json_response({"error": str(exc)}, status=400)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error importing recipe from remote source: %s", exc, exc_info=True)
"Error importing recipe from remote source: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def delete_recipe(self, request: web.Request) -> web.Response: async def delete_recipe(self, request: web.Request) -> web.Response:
@@ -904,11 +783,7 @@ class RecipeManagementHandler:
target_path = data.get("target_path") target_path = data.get("target_path")
if not recipe_id or not target_path: if not recipe_id or not target_path:
return web.json_response( return web.json_response(
{ {"success": False, "error": "recipe_id and target_path are required"}, status=400
"success": False,
"error": "recipe_id and target_path are required",
},
status=400,
) )
result = await self._persistence_service.move_recipe( result = await self._persistence_service.move_recipe(
@@ -937,11 +812,7 @@ class RecipeManagementHandler:
target_path = data.get("target_path") target_path = data.get("target_path")
if not recipe_ids or not target_path: if not recipe_ids or not target_path:
return web.json_response( return web.json_response(
{ {"success": False, "error": "recipe_ids and target_path are required"}, status=400
"success": False,
"error": "recipe_ids and target_path are required",
},
status=400,
) )
result = await self._persistence_service.move_recipes_bulk( result = await self._persistence_service.move_recipes_bulk(
@@ -1030,9 +901,7 @@ class RecipeManagementHandler:
except RecipeValidationError as exc: except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400) return web.json_response({"error": str(exc)}, status=400)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error saving recipe from widget: %s", exc, exc_info=True)
"Error saving recipe from widget: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def _parse_save_payload(self, reader) -> dict[str, Any]: async def _parse_save_payload(self, reader) -> dict[str, Any]:
@@ -1104,9 +973,7 @@ class RecipeManagementHandler:
raise RecipeValidationError("gen_params payload must be an object") raise RecipeValidationError("gen_params payload must be an object")
return parsed return parsed
def _parse_resources_payload( def _parse_resources_payload(self, payload_raw: str) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
self, payload_raw: str
) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
try: try:
payload = json.loads(payload_raw) payload = json.loads(payload_raw)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
@@ -1154,7 +1021,7 @@ class RecipeManagementHandler:
"exclude": False, "exclude": False,
} }
async def _download_remote_media(self, image_url: str) -> tuple[bytes, str, Any]: async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory() downloader = await self._downloader_factory()
temp_path = None temp_path = None
@@ -1162,18 +1029,13 @@ class RecipeManagementHandler:
with tempfile.NamedTemporaryFile(delete=False) as temp_file: with tempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_path = temp_file.name temp_path = temp_file.name
download_url = image_url download_url = image_url
image_info = None
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url) civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
if civitai_match: if civitai_match:
if civitai_client is None: if civitai_client is None:
raise RecipeDownloadError( raise RecipeDownloadError("Civitai client unavailable for image download")
"Civitai client unavailable for image download"
)
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError( raise RecipeDownloadError("Failed to fetch image information from Civitai")
"Failed to fetch image information from Civitai"
)
media_url = image_info.get("url") media_url = image_info.get("url")
if not media_url: if not media_url:
@@ -1187,24 +1049,18 @@ class RecipeManagementHandler:
else: else:
download_url = media_url download_url = media_url
success, result = await downloader.download_file( success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
download_url, temp_path, use_auth=False
)
if not success: if not success:
raise RecipeDownloadError(f"Failed to download image: {result}") raise RecipeDownloadError(f"Failed to download image: {result}")
# Extract extension from URL # Extract extension from URL
url_path = download_url.split("?")[0].split("#")[0] url_path = download_url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower() extension = os.path.splitext(url_path)[1].lower()
if not extension: if not extension:
extension = ".webp" # Default to webp if unknown extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj: with open(temp_path, "rb") as file_obj:
return ( return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
file_obj.read(),
extension,
image_info.get("meta") if civitai_match and image_info else None,
)
except RecipeDownloadError: except RecipeDownloadError:
raise raise
except RecipeValidationError: except RecipeValidationError:
@@ -1218,15 +1074,14 @@ class RecipeManagementHandler:
except FileNotFoundError: except FileNotFoundError:
pass pass
def _safe_int(self, value: Any) -> int: def _safe_int(self, value: Any) -> int:
try: try:
return int(value) return int(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 0 return 0
async def _resolve_base_model_from_checkpoint( async def _resolve_base_model_from_checkpoint(self, checkpoint_entry: Dict[str, Any]) -> str:
self, checkpoint_entry: Dict[str, Any]
) -> str:
version_id = self._safe_int(checkpoint_entry.get("modelVersionId")) version_id = self._safe_int(checkpoint_entry.get("modelVersionId"))
if not version_id: if not version_id:
@@ -1245,9 +1100,7 @@ class RecipeManagementHandler:
base_model = version_info.get("baseModel") or "" base_model = version_info.get("baseModel") or ""
return str(base_model) if base_model is not None else "" return str(base_model) if base_model is not None else ""
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
self._logger.warning( self._logger.warning("Failed to resolve base model from checkpoint metadata: %s", exc)
"Failed to resolve base model from checkpoint metadata: %s", exc
)
return "" return ""
@@ -1392,311 +1245,5 @@ class RecipeSharingHandler:
except RecipeNotFoundError as exc: except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404) return web.json_response({"error": str(exc)}, status=404)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error("Error downloading shared recipe: %s", exc, exc_info=True)
"Error downloading shared recipe: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
class BatchImportHandler:
"""Handle batch import operations for recipes."""
def __init__(
self,
*,
ensure_dependencies_ready: EnsureDependenciesCallable,
recipe_scanner_getter: RecipeScannerGetter,
civitai_client_getter: CivitaiClientGetter,
logger: Logger,
batch_import_service: BatchImportService,
) -> None:
self._ensure_dependencies_ready = ensure_dependencies_ready
self._recipe_scanner_getter = recipe_scanner_getter
self._civitai_client_getter = civitai_client_getter
self._logger = logger
self._batch_import_service = batch_import_service
async def start_batch_import(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
if self._batch_import_service.is_import_running():
return web.json_response(
{"success": False, "error": "Batch import already in progress"},
status=409,
)
data = await request.json()
items = data.get("items", [])
tags = data.get("tags", [])
skip_no_metadata = data.get("skip_no_metadata", False)
if not items:
return web.json_response(
{"success": False, "error": "No items provided"},
status=400,
)
for item in items:
if not item.get("source"):
return web.json_response(
{
"success": False,
"error": "Each item must have a 'source' field",
},
status=400,
)
operation_id = await self._batch_import_service.start_batch_import(
recipe_scanner_getter=self._recipe_scanner_getter,
civitai_client_getter=self._civitai_client_getter,
items=items,
tags=tags,
skip_no_metadata=skip_no_metadata,
)
return web.json_response(
{
"success": True,
"operation_id": operation_id,
}
)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error starting batch import: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def start_directory_import(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
if self._batch_import_service.is_import_running():
return web.json_response(
{"success": False, "error": "Batch import already in progress"},
status=409,
)
data = await request.json()
directory = data.get("directory")
recursive = data.get("recursive", True)
tags = data.get("tags", [])
skip_no_metadata = data.get("skip_no_metadata", True)
if not directory:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
operation_id = await self._batch_import_service.start_directory_import(
recipe_scanner_getter=self._recipe_scanner_getter,
civitai_client_getter=self._civitai_client_getter,
directory=directory,
recursive=recursive,
tags=tags,
skip_no_metadata=skip_no_metadata,
)
return web.json_response(
{
"success": True,
"operation_id": operation_id,
}
)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error(
"Error starting directory import: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_batch_import_progress(self, request: web.Request) -> web.Response:
try:
operation_id = request.query.get("operation_id")
if not operation_id:
return web.json_response(
{"success": False, "error": "operation_id is required"},
status=400,
)
progress = self._batch_import_service.get_progress(operation_id)
if not progress:
return web.json_response(
{"success": False, "error": "Operation not found"},
status=404,
)
return web.json_response(
{
"success": True,
"progress": progress.to_dict(),
}
)
except Exception as exc:
self._logger.error(
"Error getting batch import progress: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_batch_import(self, request: web.Request) -> web.Response:
try:
data = await request.json()
operation_id = data.get("operation_id")
if not operation_id:
return web.json_response(
{"success": False, "error": "operation_id is required"},
status=400,
)
cancelled = self._batch_import_service.cancel_import(operation_id)
if not cancelled:
return web.json_response(
{
"success": False,
"error": "Operation not found or already completed",
},
status=404,
)
return web.json_response(
{"success": True, "message": "Cancellation requested"}
)
except Exception as exc:
self._logger.error("Error cancelling batch import: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def browse_directory(self, request: web.Request) -> web.Response:
"""Browse a directory and return its contents (subdirectories and files)."""
try:
data = await request.json()
directory_path = data.get("path", "")
if not directory_path:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
# Normalize the path
path = Path(directory_path).expanduser().resolve()
# Security check: ensure path is within allowed directories
# Allow common image/model directories
allowed_roots = [
Path.home(),
Path("/"), # Allow browsing from root for flexibility
]
# Check if path is within any allowed root
is_allowed = False
for root in allowed_roots:
try:
path.relative_to(root)
is_allowed = True
break
except ValueError:
continue
if not is_allowed:
return web.json_response(
{"success": False, "error": "Access denied to this directory"},
status=403,
)
if not path.exists():
return web.json_response(
{"success": False, "error": "Directory does not exist"},
status=404,
)
if not path.is_dir():
return web.json_response(
{"success": False, "error": "Path is not a directory"},
status=400,
)
# List directory contents
directories = []
image_files = []
image_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
}
try:
for item in path.iterdir():
try:
if item.is_dir():
# Skip hidden directories and common system folders
if not item.name.startswith(".") and item.name not in [
"__pycache__",
"node_modules",
]:
directories.append(
{
"name": item.name,
"path": str(item),
"is_parent": False,
}
)
elif item.is_file() and item.suffix.lower() in image_extensions:
image_files.append(
{
"name": item.name,
"path": str(item),
"size": item.stat().st_size,
}
)
except (PermissionError, OSError):
# Skip files/directories we can't access
continue
# Sort directories and files alphabetically
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Add parent directory if not at root
parent_path = path.parent
show_parent = str(path) != str(path.root)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": str(parent_path) if show_parent else None,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
"directory_count": len(directories),
}
)
except PermissionError:
return web.json_response(
{"success": False, "error": "Permission denied"},
status=403,
)
except OSError as exc:
return web.json_response(
{"success": False, "error": f"Error reading directory: {str(exc)}"},
status=500,
)
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON"},
status=400,
)
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)

View File

@@ -0,0 +1,112 @@
import logging
from typing import Dict
from aiohttp import web
from .base_model_routes import BaseModelRoutes
from .model_route_registrar import ModelRouteRegistrar
from ..services.misc_service import MiscService
from ..services.service_registry import ServiceRegistry
from ..config import config
logger = logging.getLogger(__name__)
class MiscModelRoutes(BaseModelRoutes):
"""Misc-specific route controller (VAE, Upscaler)"""
def __init__(self):
"""Initialize Misc routes with Misc service"""
super().__init__()
self.template_name = "misc.html"
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
misc_scanner = await ServiceRegistry.get_misc_scanner()
update_service = await ServiceRegistry.get_model_update_service()
self.service = MiscService(misc_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)
def setup_routes(self, app: web.Application):
"""Setup Misc routes"""
# Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'misc' prefix (includes page route)
super().setup_routes(app, 'misc')
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup Misc-specific routes"""
# Misc info by name
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_misc_info)
# VAE roots and Upscaler roots
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/vae_roots', prefix, self.get_vae_roots)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/upscaler_roots', prefix, self.get_upscaler_roots)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Misc (VAE or Upscaler)"""
return model_type.lower() in ['vae', 'upscaler']
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
return "VAE or Upscaler"
def _parse_specific_params(self, request: web.Request) -> Dict:
"""Parse Misc-specific parameters"""
params: Dict = {}
if 'misc_hash' in request.query:
params['hash_filters'] = {'single_hash': request.query['misc_hash'].lower()}
elif 'misc_hashes' in request.query:
params['hash_filters'] = {
'multiple_hashes': [h.lower() for h in request.query['misc_hashes'].split(',')]
}
return params
async def get_misc_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific misc model by name"""
try:
name = request.match_info.get('name', '')
misc_info = await self.service.get_model_info_by_name(name)
if misc_info:
return web.json_response(misc_info)
else:
return web.json_response({"error": "Misc model not found"}, status=404)
except Exception as e:
logger.error(f"Error in get_misc_info: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
async def get_vae_roots(self, request: web.Request) -> web.Response:
"""Return the list of VAE roots from config"""
try:
roots = config.vae_roots
return web.json_response({
"success": True,
"roots": roots
})
except Exception as e:
logger.error(f"Error getting VAE roots: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
async def get_upscaler_roots(self, request: web.Request) -> web.Response:
"""Return the list of upscaler roots from config"""
try:
roots = config.upscaler_roots
return web.json_response({
"success": True,
"roots": roots
})
except Exception as e:
logger.error(f"Error getting upscaler roots: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)

View File

@@ -26,7 +26,6 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"), RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"), RouteDefinition("POST", "/api/lm/open-file-location", "open_file_location"),
RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"), RouteDefinition("POST", "/api/lm/update-usage-stats", "update_usage_stats"),
RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"), RouteDefinition("GET", "/api/lm/get-usage-stats", "get_usage_stats"),
@@ -38,24 +37,12 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"), RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"), RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"), RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
RouteDefinition( RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
"POST", "/api/lm/download-metadata-archive", "download_metadata_archive" RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
), RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
RouteDefinition( RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
"POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"
),
RouteDefinition(
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
),
RouteDefinition(
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
),
RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"), RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"),
RouteDefinition("GET", "/api/lm/custom-words/search", "search_custom_words"), RouteDefinition("GET", "/api/lm/custom-words/search", "search_custom_words"),
RouteDefinition("GET", "/api/lm/example-workflows", "get_example_workflows"),
RouteDefinition(
"GET", "/api/lm/example-workflows/{filename}", "get_example_workflow"
),
) )
@@ -79,11 +66,7 @@ class MiscRouteRegistrar:
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS, definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
) -> None: ) -> None:
for definition in definitions: for definition in definitions:
self._bind( self._bind(definition.method, definition.path, handler_lookup[definition.handler_name])
definition.method,
definition.path,
handler_lookup[definition.handler_name],
)
def _bind(self, method: str, path: str, handler: Callable) -> None: def _bind(self, method: str, path: str, handler: Callable) -> None:
add_method_name = self._METHOD_MAP[method.upper()] add_method_name = self._METHOD_MAP[method.upper()]

View File

@@ -19,7 +19,6 @@ from ..services.downloader import get_downloader
from ..utils.usage_stats import UsageStats from ..utils.usage_stats import UsageStats
from .handlers.misc_handlers import ( from .handlers.misc_handlers import (
CustomWordsHandler, CustomWordsHandler,
ExampleWorkflowsHandler,
FileSystemHandler, FileSystemHandler,
HealthCheckHandler, HealthCheckHandler,
LoraCodeHandler, LoraCodeHandler,
@@ -30,7 +29,6 @@ from .handlers.misc_handlers import (
NodeRegistry, NodeRegistry,
NodeRegistryHandler, NodeRegistryHandler,
SettingsHandler, SettingsHandler,
SupportersHandler,
TrainedWordsHandler, TrainedWordsHandler,
UsageStatsHandler, UsageStatsHandler,
build_service_registry_adapter, build_service_registry_adapter,
@@ -39,10 +37,9 @@ from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
standalone_mode = ( standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get(
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" "HF_HUB_DISABLE_TELEMETRY", "0"
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" ) == "0"
)
class MiscRoutes: class MiscRoutes:
@@ -77,9 +74,7 @@ class MiscRoutes:
self._node_registry = node_registry or NodeRegistry() self._node_registry = node_registry or NodeRegistry()
self._standalone_mode = standalone_mode_flag self._standalone_mode = standalone_mode_flag
self._handler_mapping: ( self._handler_mapping: Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None = None
Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None
) = None
@staticmethod @staticmethod
def setup_routes(app: web.Application) -> None: def setup_routes(app: web.Application) -> None:
@@ -91,9 +86,7 @@ class MiscRoutes:
registrar = self._registrar_factory(app) registrar = self._registrar_factory(app)
registrar.register_routes(self._ensure_handler_mapping()) registrar.register_routes(self._ensure_handler_mapping())
def _ensure_handler_mapping( def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
if self._handler_mapping is None: if self._handler_mapping is None:
handler_set = self._create_handler_set() handler_set = self._create_handler_set()
self._handler_mapping = handler_set.to_route_mapping() self._handler_mapping = handler_set.to_route_mapping()
@@ -126,8 +119,6 @@ class MiscRoutes:
metadata_provider_factory=self._metadata_provider_factory, metadata_provider_factory=self._metadata_provider_factory,
) )
custom_words = CustomWordsHandler() custom_words = CustomWordsHandler()
supporters = SupportersHandler()
example_workflows = ExampleWorkflowsHandler()
return self._handler_set_factory( return self._handler_set_factory(
health=health, health=health,
@@ -141,8 +132,6 @@ class MiscRoutes:
metadata_archive=metadata_archive, metadata_archive=metadata_archive,
filesystem=filesystem, filesystem=filesystem,
custom_words=custom_words, custom_words=custom_words,
supporters=supporters,
example_workflows=example_workflows,
) )

View File

@@ -1,5 +1,4 @@
"""Route registrar for model endpoints.""" """Route registrar for model endpoints."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -28,9 +27,6 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/replace-preview", "replace_preview"), RouteDefinition("POST", "/api/lm/{prefix}/replace-preview", "replace_preview"),
RouteDefinition(
"POST", "/api/lm/{prefix}/set-preview-from-url", "set_preview_from_url"
),
RouteDefinition("POST", "/api/lm/{prefix}/save-metadata", "save_metadata"), RouteDefinition("POST", "/api/lm/{prefix}/save-metadata", "save_metadata"),
RouteDefinition("POST", "/api/lm/{prefix}/add-tags", "add_tags"), RouteDefinition("POST", "/api/lm/{prefix}/add-tags", "add_tags"),
RouteDefinition("POST", "/api/lm/{prefix}/rename", "rename_model"), RouteDefinition("POST", "/api/lm/{prefix}/rename", "rename_model"),
@@ -40,9 +36,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"), RouteDefinition("POST", "/api/lm/{prefix}/move_models_bulk", "move_models_bulk"),
RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("GET", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"), RouteDefinition("POST", "/api/lm/{prefix}/auto-organize", "auto_organize_models"),
RouteDefinition( RouteDefinition("GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"),
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"), RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
@@ -50,60 +44,30 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"), RouteDefinition("GET", "/api/lm/{prefix}/roots", "get_model_roots"),
RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"), RouteDefinition("GET", "/api/lm/{prefix}/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"), RouteDefinition("GET", "/api/lm/{prefix}/folder-tree", "get_folder_tree"),
RouteDefinition( RouteDefinition("GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"),
"GET", "/api/lm/{prefix}/unified-folder-tree", "get_unified_folder_tree"
),
RouteDefinition("GET", "/api/lm/{prefix}/find-duplicates", "find_duplicate_models"), RouteDefinition("GET", "/api/lm/{prefix}/find-duplicates", "find_duplicate_models"),
RouteDefinition( RouteDefinition("GET", "/api/lm/{prefix}/find-filename-conflicts", "find_filename_conflicts"),
"GET", "/api/lm/{prefix}/find-filename-conflicts", "find_filename_conflicts"
),
RouteDefinition("GET", "/api/lm/{prefix}/get-notes", "get_model_notes"), RouteDefinition("GET", "/api/lm/{prefix}/get-notes", "get_model_notes"),
RouteDefinition("GET", "/api/lm/{prefix}/preview-url", "get_model_preview_url"), RouteDefinition("GET", "/api/lm/{prefix}/preview-url", "get_model_preview_url"),
RouteDefinition("GET", "/api/lm/{prefix}/civitai-url", "get_model_civitai_url"), RouteDefinition("GET", "/api/lm/{prefix}/civitai-url", "get_model_civitai_url"),
RouteDefinition("GET", "/api/lm/{prefix}/metadata", "get_model_metadata"), RouteDefinition("GET", "/api/lm/{prefix}/metadata", "get_model_metadata"),
RouteDefinition( RouteDefinition("GET", "/api/lm/{prefix}/model-description", "get_model_description"),
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
),
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"), RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
RouteDefinition( RouteDefinition("GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"),
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions" RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/version/{modelVersionId}", "get_civitai_model_by_version"),
), RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"),
RouteDefinition( RouteDefinition("POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"),
"GET", RouteDefinition("POST", "/api/lm/{prefix}/updates/fetch-missing-license", "fetch_missing_civitai_license_data"),
"/api/lm/{prefix}/civitai/model/version/{modelVersionId}", RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"),
"get_civitai_model_by_version", RouteDefinition("POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"),
), RouteDefinition("GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"),
RouteDefinition( RouteDefinition("GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"),
"GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"
),
RouteDefinition(
"POST",
"/api/lm/{prefix}/updates/fetch-missing-license",
"fetch_missing_civitai_license_data",
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/ignore", "set_model_update_ignore"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/updates/ignore-version", "set_version_update_ignore"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/updates/status/{model_id}", "get_model_update_status"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/updates/versions/{model_id}", "get_model_versions"
),
RouteDefinition("POST", "/api/lm/download-model", "download_model"), RouteDefinition("POST", "/api/lm/download-model", "download_model"),
RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"), RouteDefinition("GET", "/api/lm/download-model-get", "download_model_get"),
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"), RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"), RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"), RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition( RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
"GET", "/api/lm/download-progress/{download_id}", "get_download_progress"
),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"), RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )
@@ -130,18 +94,12 @@ class ModelRouteRegistrar:
definitions: Iterable[RouteDefinition] = COMMON_ROUTE_DEFINITIONS, definitions: Iterable[RouteDefinition] = COMMON_ROUTE_DEFINITIONS,
) -> None: ) -> None:
for definition in definitions: for definition in definitions:
self._bind_route( self._bind_route(definition.method, definition.build_path(prefix), handler_lookup[definition.handler_name])
definition.method,
definition.build_path(prefix),
handler_lookup[definition.handler_name],
)
def add_route(self, method: str, path: str, handler: Callable) -> None: def add_route(self, method: str, path: str, handler: Callable) -> None:
self._bind_route(method, path, handler) self._bind_route(method, path, handler)
def add_prefixed_route( def add_prefixed_route(self, method: str, path_template: str, prefix: str, handler: Callable) -> None:
self, method: str, path_template: str, prefix: str, handler: Callable
) -> None:
self._bind_route(method, path_template.replace("{prefix}", prefix), handler) self._bind_route(method, path_template.replace("{prefix}", prefix), handler)
def _bind_route(self, method: str, path: str, handler: Callable) -> None: def _bind_route(self, method: str, path: str, handler: Callable) -> None:

View File

@@ -1,5 +1,4 @@
"""Route registrar for recipe endpoints.""" """Route registrar for recipe endpoints."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -23,9 +22,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_recipe"),
RouteDefinition("GET", "/api/lm/recipes/import-remote", "import_remote_recipe"), RouteDefinition("GET", "/api/lm/recipes/import-remote", "import_remote_recipe"),
RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"), RouteDefinition("POST", "/api/lm/recipes/analyze-image", "analyze_uploaded_image"),
RouteDefinition( RouteDefinition("POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"),
"POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"
),
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"), RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
@@ -33,13 +30,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"), RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"), RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"), RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
RouteDefinition( RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
"GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
RouteDefinition( RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
"GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
@@ -47,26 +40,13 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition( RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
),
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"), RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"), RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
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/batch-import/start", "start_batch_import"),
RouteDefinition(
"GET", "/api/lm/recipes/batch-import/progress", "get_batch_import_progress"
),
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/cancel", "cancel_batch_import"
),
RouteDefinition(
"POST", "/api/lm/recipes/batch-import/directory", "start_directory_import"
),
RouteDefinition("POST", "/api/lm/recipes/browse-directory", "browse_directory"),
) )
@@ -83,9 +63,7 @@ class RecipeRouteRegistrar:
def __init__(self, app: web.Application) -> None: def __init__(self, app: web.Application) -> None:
self._app = app self._app = app
def register_routes( def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None:
self, handler_lookup: Mapping[str, Callable[[web.Request], object]]
) -> None:
for definition in ROUTE_DEFINITIONS: for definition in ROUTE_DEFINITIONS:
handler = handler_lookup[definition.handler_name] handler = handler_lookup[definition.handler_name]
self._bind_route(definition.method, definition.path, handler) self._bind_route(definition.method, definition.path, handler)

View File

@@ -209,80 +209,6 @@ class StatsRoutes:
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
async def get_model_usage_list(self, request: web.Request) -> web.Response:
"""Get paginated model usage list for infinite scrolling"""
try:
await self.init_services()
model_type = request.query.get('type', 'lora')
sort_order = request.query.get('sort', 'desc')
try:
limit = int(request.query.get('limit', '50'))
offset = int(request.query.get('offset', '0'))
except ValueError:
limit = 50
offset = 0
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Select proper cache and usage dict based on type
if model_type == 'lora':
cache = await self.lora_scanner.get_cached_data()
type_usage_data = usage_data.get('loras', {})
elif model_type == 'checkpoint':
cache = await self.checkpoint_scanner.get_cached_data()
type_usage_data = usage_data.get('checkpoints', {})
elif model_type == 'embedding':
cache = await self.embedding_scanner.get_cached_data()
type_usage_data = usage_data.get('embeddings', {})
else:
return web.json_response({'success': False, 'error': f"Invalid model type: {model_type}"}, status=400)
# Create list of all models
all_models = []
for item in cache.raw_data:
sha256 = item.get('sha256')
usage_info = type_usage_data.get(sha256, {}) if sha256 else {}
usage_count = usage_info.get('total', 0) if isinstance(usage_info, dict) else 0
all_models.append({
'name': item.get('model_name', 'Unknown'),
'usage_count': usage_count,
'base_model': item.get('base_model', 'Unknown'),
'preview_url': config.get_preview_static_url(item.get('preview_url', '')),
'folder': item.get('folder', '')
})
# Sort the models
reverse = (sort_order == 'desc')
all_models.sort(key=lambda x: (x['usage_count'], x['name'].lower()), reverse=reverse)
if not reverse:
# If asc, sort by usage_count ascending, but keep name ascending
all_models.sort(key=lambda x: (x['usage_count'], x['name'].lower()))
else:
all_models.sort(key=lambda x: (-x['usage_count'], x['name'].lower()))
# Slice for pagination
paginated_models = all_models[offset:offset + limit]
return web.json_response({
'success': True,
'data': {
'items': paginated_models,
'total': len(all_models),
'type': model_type
}
})
except Exception as e:
logger.error(f"Error getting model usage list: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_base_model_distribution(self, request: web.Request) -> web.Response: async def get_base_model_distribution(self, request: web.Request) -> web.Response:
"""Get base model distribution statistics""" """Get base model distribution statistics"""
try: try:
@@ -604,7 +530,6 @@ class StatsRoutes:
# Register API routes # Register API routes
app.router.add_get('/api/lm/stats/collection-overview', self.get_collection_overview) app.router.add_get('/api/lm/stats/collection-overview', self.get_collection_overview)
app.router.add_get('/api/lm/stats/usage-analytics', self.get_usage_analytics) app.router.add_get('/api/lm/stats/usage-analytics', self.get_usage_analytics)
app.router.add_get('/api/lm/stats/model-usage-list', self.get_model_usage_list)
app.router.add_get('/api/lm/stats/base-model-distribution', self.get_base_model_distribution) app.router.add_get('/api/lm/stats/base-model-distribution', self.get_base_model_distribution)
app.router.add_get('/api/lm/stats/tag-analytics', self.get_tag_analytics) app.router.add_get('/api/lm/stats/tag-analytics', self.get_tag_analytics)
app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics) app.router.add_get('/api/lm/stats/storage-analytics', self.get_storage_analytics)

View File

@@ -1,6 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import re
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
import logging import logging
import os import os
@@ -82,7 +81,6 @@ class BaseModelService(ABC):
update_available_only: bool = False, update_available_only: bool = False,
credit_required: Optional[bool] = None, credit_required: Optional[bool] = None,
allow_selling_generated_content: Optional[bool] = None, allow_selling_generated_content: Optional[bool] = None,
tag_logic: str = "any",
**kwargs, **kwargs,
) -> Dict: ) -> Dict:
"""Get paginated and filtered model data""" """Get paginated and filtered model data"""
@@ -111,7 +109,6 @@ class BaseModelService(ABC):
tags=tags, tags=tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=search_options, search_options=search_options,
tag_logic=tag_logic,
) )
if search: if search:
@@ -208,11 +205,7 @@ class BaseModelService(ABC):
reverse = sort_params.order == "desc" reverse = sort_params.order == "desc"
annotated.sort( annotated.sort(
key=lambda x: ( key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
x.get("usage_count", 0),
x.get("model_name", "").lower(),
x.get("file_path", "").lower()
),
reverse=reverse, reverse=reverse,
) )
return annotated return annotated
@@ -248,7 +241,6 @@ class BaseModelService(ABC):
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
favorites_only: bool = False, favorites_only: bool = False,
search_options: dict = None, search_options: dict = None,
tag_logic: str = "any",
) -> List[Dict]: ) -> List[Dict]:
"""Apply common filters that work across all model types""" """Apply common filters that work across all model types"""
normalized_options = self.search_strategy.normalize_options(search_options) normalized_options = self.search_strategy.normalize_options(search_options)
@@ -261,7 +253,6 @@ class BaseModelService(ABC):
tags=tags, tags=tags,
favorites_only=favorites_only, favorites_only=favorites_only,
search_options=normalized_options, search_options=normalized_options,
tag_logic=tag_logic,
) )
return self.filter_set.apply(data, criteria) return self.filter_set.apply(data, criteria)
@@ -385,15 +376,6 @@ class BaseModelService(ABC):
strategy = "same_base" strategy = "same_base"
same_base_mode = strategy == "same_base" same_base_mode = strategy == "same_base"
# Check user setting for hiding early access updates
hide_early_access = False
try:
hide_early_access = bool(
self.settings.get("hide_early_access_updates", False)
)
except Exception:
hide_early_access = False
records = None records = None
resolved: Optional[Dict[int, bool]] = None resolved: Optional[Dict[int, bool]] = None
if same_base_mode: if same_base_mode:
@@ -402,7 +384,7 @@ class BaseModelService(ABC):
try: try:
records = await record_method(self.model_type, ordered_ids) records = await record_method(self.model_type, ordered_ids)
resolved = { resolved = {
model_id: record.has_update(hide_early_access=hide_early_access) model_id: record.has_update()
for model_id, record in records.items() for model_id, record in records.items()
} }
except Exception as exc: except Exception as exc:
@@ -420,11 +402,7 @@ class BaseModelService(ABC):
bulk_method = getattr(self.update_service, "has_updates_bulk", None) bulk_method = getattr(self.update_service, "has_updates_bulk", None)
if callable(bulk_method): if callable(bulk_method):
try: try:
resolved = await bulk_method( resolved = await bulk_method(self.model_type, ordered_ids)
self.model_type,
ordered_ids,
hide_early_access=hide_early_access,
)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"Failed to resolve update status in bulk for %s models (%s): %s", "Failed to resolve update status in bulk for %s models (%s): %s",
@@ -437,9 +415,7 @@ class BaseModelService(ABC):
if resolved is None: if resolved is None:
tasks = [ tasks = [
self.update_service.has_update( self.update_service.has_update(self.model_type, model_id)
self.model_type, model_id, hide_early_access=hide_early_access
)
for model_id in ordered_ids for model_id in ordered_ids
] ]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -477,7 +453,6 @@ class BaseModelService(ABC):
flag = record.has_update_for_base( flag = record.has_update_for_base(
threshold_version, threshold_version,
base_model, base_model,
hide_early_access=hide_early_access,
) )
else: else:
flag = default_flag flag = default_flag
@@ -603,15 +578,9 @@ class BaseModelService(ABC):
continue continue
# Filter by valid sub-types based on scanner type # Filter by valid sub-types based on scanner type
if ( if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES:
self.model_type == "lora"
and normalized_type not in VALID_LORA_SUB_TYPES
):
continue continue
if ( if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES:
self.model_type == "checkpoint"
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
):
continue continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1 type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
@@ -826,61 +795,38 @@ class BaseModelService(ABC):
return include_terms, exclude_terms return include_terms, exclude_terms
@staticmethod
def _remove_model_extension(path: str) -> str:
"""Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching."""
return re.sub(r"\.(safetensors|ckpt|pt|bin)$", "", path, flags=re.IGNORECASE)
@staticmethod @staticmethod
def _relative_path_matches_tokens( def _relative_path_matches_tokens(
path_lower: str, include_terms: List[str], exclude_terms: List[str] path_lower: str, include_terms: List[str], exclude_terms: List[str]
) -> bool: ) -> bool:
"""Determine whether a relative path string satisfies include/exclude tokens. """Determine whether a relative path string satisfies include/exclude tokens."""
if any(term and term in path_lower for term in exclude_terms):
Matches against the path without extension to avoid matching .safetensors
when searching for 's'.
"""
# Use path without extension for matching
path_for_matching = BaseModelService._remove_model_extension(path_lower)
if any(term and term in path_for_matching for term in exclude_terms):
return False return False
for term in include_terms: for term in include_terms:
if term and term not in path_for_matching: if term and term not in path_lower:
return False return False
return True return True
@staticmethod @staticmethod
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple: def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
"""Sort paths by how well they satisfy the include tokens. """Sort paths by how well they satisfy the include tokens."""
path_lower = relative_path.lower()
Sorts based on path without extension for consistent ordering.
"""
# Use path without extension for sorting
path_for_sorting = BaseModelService._remove_model_extension(
relative_path.lower()
)
prefix_hits = sum( prefix_hits = sum(
1 for term in include_terms if term and path_for_sorting.startswith(term) 1 for term in include_terms if term and path_lower.startswith(term)
) )
match_positions = [ match_positions = [
path_for_sorting.find(term) path_lower.find(term)
for term in include_terms for term in include_terms
if term and term in path_for_sorting if term and term in path_lower
] ]
first_match_index = min(match_positions) if match_positions else 0 first_match_index = min(match_positions) if match_positions else 0
return ( return (-prefix_hits, first_match_index, len(relative_path), path_lower)
-prefix_hits,
first_match_index,
len(path_for_sorting),
path_for_sorting,
)
async def search_relative_paths( async def search_relative_paths(
self, search_term: str, limit: int = 15, offset: int = 0 self, search_term: str, limit: int = 15
) -> List[str]: ) -> List[str]:
"""Search model relative file paths for autocomplete functionality""" """Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -891,7 +837,6 @@ class BaseModelService(ABC):
# Get model roots for path calculation # Get model roots for path calculation
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset)
for model in cache.raw_data: for model in cache.raw_data:
file_path = model.get("file_path", "") file_path = model.get("file_path", "")
if not file_path: if not file_path:
@@ -920,12 +865,12 @@ class BaseModelService(ABC):
): ):
matching_paths.append(relative_path) matching_paths.append(relative_path)
if len(matching_paths) >= limit * 2: # Get more for better sorting
break
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically) # Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
matching_paths.sort( matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms) key=lambda relative: self._relative_path_sort_key(relative, include_terms)
) )
# Apply offset and limit return matching_paths[:limit]
start = min(offset, len(matching_paths))
end = min(start + limit, len(matching_paths))
return matching_paths[start:end]

View File

@@ -1,593 +0,0 @@
"""Batch import service for importing multiple images as recipes."""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Set
from aiohttp import web
from .recipes import (
RecipeAnalysisService,
RecipePersistenceService,
RecipeValidationError,
RecipeDownloadError,
RecipeNotFoundError,
)
class ImportItemType(Enum):
"""Type of import item."""
URL = "url"
LOCAL_PATH = "local_path"
class ImportStatus(Enum):
"""Status of an individual import item."""
PENDING = "pending"
PROCESSING = "processing"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
@dataclass
class BatchImportItem:
"""Represents a single item to import."""
id: str
source: str
item_type: ImportItemType
status: ImportStatus = ImportStatus.PENDING
error_message: Optional[str] = None
recipe_name: Optional[str] = None
recipe_id: Optional[str] = None
duration: float = 0.0
@dataclass
class BatchImportProgress:
"""Tracks progress of a batch import operation."""
operation_id: str
total: int
completed: int = 0
success: int = 0
failed: int = 0
skipped: int = 0
current_item: str = ""
status: str = "pending"
started_at: float = field(default_factory=time.time)
finished_at: Optional[float] = None
items: List[BatchImportItem] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
skip_no_metadata: bool = False
skip_duplicates: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
"operation_id": self.operation_id,
"total": self.total,
"completed": self.completed,
"success": self.success,
"failed": self.failed,
"skipped": self.skipped,
"current_item": self.current_item,
"status": self.status,
"started_at": self.started_at,
"finished_at": self.finished_at,
"progress_percent": round((self.completed / self.total) * 100, 1)
if self.total > 0
else 0,
"items": [
{
"id": item.id,
"source": item.source,
"item_type": item.item_type.value,
"status": item.status.value,
"error_message": item.error_message,
"recipe_name": item.recipe_name,
"recipe_id": item.recipe_id,
"duration": item.duration,
}
for item in self.items
],
}
class AdaptiveConcurrencyController:
"""Adjusts concurrency based on task performance."""
def __init__(
self,
min_concurrency: int = 1,
max_concurrency: int = 5,
initial_concurrency: int = 3,
) -> None:
self.min_concurrency = min_concurrency
self.max_concurrency = max_concurrency
self.current_concurrency = initial_concurrency
self._task_durations: List[float] = []
self._recent_errors = 0
self._recent_successes = 0
def record_result(self, duration: float, success: bool) -> None:
self._task_durations.append(duration)
if len(self._task_durations) > 10:
self._task_durations.pop(0)
if success:
self._recent_successes += 1
if duration < 1.0 and self.current_concurrency < self.max_concurrency:
self.current_concurrency = min(
self.current_concurrency + 1, self.max_concurrency
)
elif duration > 10.0 and self.current_concurrency > self.min_concurrency:
self.current_concurrency = max(
self.current_concurrency - 1, self.min_concurrency
)
else:
self._recent_errors += 1
if self.current_concurrency > self.min_concurrency:
self.current_concurrency = max(
self.current_concurrency - 1, self.min_concurrency
)
def reset_counters(self) -> None:
self._recent_errors = 0
self._recent_successes = 0
def get_semaphore(self) -> asyncio.Semaphore:
return asyncio.Semaphore(self.current_concurrency)
class BatchImportService:
"""Service for batch importing images as recipes."""
SUPPORTED_EXTENSIONS: Set[str] = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
def __init__(
self,
*,
analysis_service: RecipeAnalysisService,
persistence_service: RecipePersistenceService,
ws_manager: Any,
logger: logging.Logger,
) -> None:
self._analysis_service = analysis_service
self._persistence_service = persistence_service
self._ws_manager = ws_manager
self._logger = logger
self._active_operations: Dict[str, BatchImportProgress] = {}
self._cancellation_flags: Dict[str, bool] = {}
self._concurrency_controller = AdaptiveConcurrencyController()
def is_import_running(self, operation_id: Optional[str] = None) -> bool:
if operation_id:
progress = self._active_operations.get(operation_id)
return progress is not None and progress.status in ("pending", "running")
return any(
p.status in ("pending", "running") for p in self._active_operations.values()
)
def get_progress(self, operation_id: str) -> Optional[BatchImportProgress]:
return self._active_operations.get(operation_id)
def cancel_import(self, operation_id: str) -> bool:
if operation_id in self._active_operations:
self._cancellation_flags[operation_id] = True
return True
return False
def _validate_url(self, url: str) -> bool:
import re
url_pattern = re.compile(
r"^https?://"
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|"
r"localhost|"
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
r"(?::\d+)?"
r"(?:/?|[/?]\S+)$",
re.IGNORECASE,
)
return url_pattern.match(url) is not None
def _validate_local_path(self, path: str) -> bool:
try:
normalized = os.path.normpath(path)
if not os.path.isabs(normalized):
return False
if ".." in normalized:
return False
return True
except Exception:
return False
def _is_duplicate_source(
self,
source: str,
item_type: ImportItemType,
recipe_scanner: Any,
) -> bool:
try:
cache = recipe_scanner.get_cached_data_sync()
if not cache:
return False
for recipe in getattr(cache, "raw_data", []):
source_path = recipe.get("source_path") or recipe.get("source_url")
if source_path and source_path == source:
return True
return False
except Exception:
self._logger.warning("Failed to check for duplicates", exc_info=True)
return False
async def start_batch_import(
self,
*,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
items: List[Dict[str, str]],
tags: Optional[List[str]] = None,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
operation_id = str(uuid.uuid4())
import_items = []
for idx, item in enumerate(items):
source = item.get("source", "")
item_type_str = item.get("type", "url")
if item_type_str == "url" or source.startswith(("http://", "https://")):
item_type = ImportItemType.URL
else:
item_type = ImportItemType.LOCAL_PATH
batch_import_item = BatchImportItem(
id=f"{operation_id}_{idx}",
source=source,
item_type=item_type,
)
import_items.append(batch_import_item)
progress = BatchImportProgress(
operation_id=operation_id,
total=len(import_items),
items=import_items,
tags=tags or [],
skip_no_metadata=skip_no_metadata,
skip_duplicates=skip_duplicates,
)
self._active_operations[operation_id] = progress
self._cancellation_flags[operation_id] = False
asyncio.create_task(
self._run_batch_import(
operation_id=operation_id,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
)
)
return operation_id
async def start_directory_import(
self,
*,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
directory: str,
recursive: bool = True,
tags: Optional[List[str]] = None,
skip_no_metadata: bool = False,
skip_duplicates: bool = False,
) -> str:
image_paths = await self._discover_images(directory, recursive)
items = [{"source": path, "type": "local_path"} for path in image_paths]
return await self.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=items,
tags=tags,
skip_no_metadata=skip_no_metadata,
skip_duplicates=skip_duplicates,
)
async def _discover_images(
self,
directory: str,
recursive: bool = True,
) -> List[str]:
if not os.path.isdir(directory):
raise RecipeValidationError(f"Directory not found: {directory}")
image_paths: List[str] = []
if recursive:
for root, _, files in os.walk(directory):
for filename in files:
if self._is_supported_image(filename):
image_paths.append(os.path.join(root, filename))
else:
for filename in os.listdir(directory):
filepath = os.path.join(directory, filename)
if os.path.isfile(filepath) and self._is_supported_image(filename):
image_paths.append(filepath)
return sorted(image_paths)
def _is_supported_image(self, filename: str) -> bool:
ext = os.path.splitext(filename)[1].lower()
return ext in self.SUPPORTED_EXTENSIONS
async def _run_batch_import(
self,
*,
operation_id: str,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
) -> None:
progress = self._active_operations.get(operation_id)
if not progress:
return
progress.status = "running"
await self._broadcast_progress(progress)
self._concurrency_controller = AdaptiveConcurrencyController()
async def process_item(item: BatchImportItem) -> None:
if self._cancellation_flags.get(operation_id, False):
return
progress.current_item = (
os.path.basename(item.source)
if item.item_type == ImportItemType.LOCAL_PATH
else item.source[:50]
)
item.status = ImportStatus.PROCESSING
await self._broadcast_progress(progress)
start_time = time.time()
try:
result = await self._import_single_item(
item=item,
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
tags=progress.tags,
skip_no_metadata=progress.skip_no_metadata,
skip_duplicates=progress.skip_duplicates,
semaphore=self._concurrency_controller.get_semaphore(),
)
duration = time.time() - start_time
item.duration = duration
self._concurrency_controller.record_result(
duration, result.get("success", False)
)
if result.get("success"):
item.status = ImportStatus.SUCCESS
item.recipe_name = result.get("recipe_name")
item.recipe_id = result.get("recipe_id")
progress.success += 1
elif result.get("skipped"):
item.status = ImportStatus.SKIPPED
item.error_message = result.get("error")
progress.skipped += 1
else:
item.status = ImportStatus.FAILED
item.error_message = result.get("error")
progress.failed += 1
except Exception as e:
self._logger.error(f"Error importing {item.source}: {e}")
item.status = ImportStatus.FAILED
item.error_message = str(e)
item.duration = time.time() - start_time
progress.failed += 1
self._concurrency_controller.record_result(item.duration, False)
progress.completed += 1
await self._broadcast_progress(progress)
tasks = [process_item(item) for item in progress.items]
await asyncio.gather(*tasks, return_exceptions=True)
if self._cancellation_flags.get(operation_id, False):
progress.status = "cancelled"
else:
progress.status = "completed"
progress.finished_at = time.time()
progress.current_item = ""
await self._broadcast_progress(progress)
await asyncio.sleep(5)
self._cleanup_operation(operation_id)
async def _import_single_item(
self,
*,
item: BatchImportItem,
recipe_scanner_getter: Callable[[], Any],
civitai_client_getter: Callable[[], Any],
tags: List[str],
skip_no_metadata: bool,
skip_duplicates: bool,
semaphore: asyncio.Semaphore,
) -> Dict[str, Any]:
async with semaphore:
recipe_scanner = recipe_scanner_getter()
if recipe_scanner is None:
return {"success": False, "error": "Recipe scanner unavailable"}
try:
if item.item_type == ImportItemType.URL:
if not self._validate_url(item.source):
return {
"success": False,
"error": f"Invalid URL format: {item.source}",
}
if skip_duplicates:
if self._is_duplicate_source(
item.source, item.item_type, recipe_scanner
):
return {
"success": False,
"skipped": True,
"error": "Duplicate source URL",
}
civitai_client = civitai_client_getter()
analysis_result = await self._analysis_service.analyze_remote_image(
url=item.source,
recipe_scanner=recipe_scanner,
civitai_client=civitai_client,
)
else:
if not self._validate_local_path(item.source):
return {
"success": False,
"error": f"Invalid or unsafe path: {item.source}",
}
if not os.path.exists(item.source):
return {
"success": False,
"error": f"File not found: {item.source}",
}
if skip_duplicates:
if self._is_duplicate_source(
item.source, item.item_type, recipe_scanner
):
return {
"success": False,
"skipped": True,
"error": "Duplicate source path",
}
analysis_result = await self._analysis_service.analyze_local_image(
file_path=item.source,
recipe_scanner=recipe_scanner,
)
payload = analysis_result.payload
if payload.get("error"):
if skip_no_metadata and "No metadata" in payload.get("error", ""):
return {
"success": False,
"skipped": True,
"error": payload["error"],
}
return {"success": False, "error": payload["error"]}
loras = payload.get("loras", [])
if not loras:
if skip_no_metadata:
return {
"success": False,
"skipped": True,
"error": "No LoRAs found in image",
}
# When skip_no_metadata is False, allow importing images without LoRAs
# Continue with empty loras list
recipe_name = self._generate_recipe_name(item, payload)
all_tags = list(set(tags + (payload.get("tags", []) or [])))
metadata = {
"base_model": payload.get("base_model", ""),
"loras": loras,
"gen_params": payload.get("gen_params", {}),
"source_path": item.source,
}
if payload.get("checkpoint"):
metadata["checkpoint"] = payload["checkpoint"]
image_bytes = None
image_base64 = payload.get("image_base64")
if item.item_type == ImportItemType.LOCAL_PATH:
with open(item.source, "rb") as f:
image_bytes = f.read()
image_base64 = None
save_result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner,
image_bytes=image_bytes,
image_base64=image_base64,
name=recipe_name,
tags=all_tags,
metadata=metadata,
extension=payload.get("extension"),
)
if save_result.status == 200:
return {
"success": True,
"recipe_name": recipe_name,
"recipe_id": save_result.payload.get("id"),
}
else:
return {
"success": False,
"error": save_result.payload.get(
"error", "Failed to save recipe"
),
}
except RecipeValidationError as e:
return {"success": False, "error": str(e)}
except RecipeDownloadError as e:
return {"success": False, "error": str(e)}
except RecipeNotFoundError as e:
return {"success": False, "skipped": True, "error": str(e)}
except Exception as e:
self._logger.error(
f"Unexpected error importing {item.source}: {e}", exc_info=True
)
return {"success": False, "error": str(e)}
def _generate_recipe_name(
self, item: BatchImportItem, payload: Dict[str, Any]
) -> str:
if item.item_type == ImportItemType.LOCAL_PATH:
base_name = os.path.splitext(os.path.basename(item.source))[0]
return base_name[:100]
else:
loras = payload.get("loras", [])
if loras:
first_lora = loras[0].get("name", "Recipe")
return f"Import - {first_lora}"[:100]
return f"Imported Recipe {item.id[:8]}"
async def _broadcast_progress(self, progress: BatchImportProgress) -> None:
await self._ws_manager.broadcast(
{
"type": "batch_import_progress",
**progress.to_dict(),
}
)
def _cleanup_operation(self, operation_id: str) -> None:
if operation_id in self._cancellation_flags:
del self._cancellation_flags[operation_id]

View File

@@ -1,291 +0,0 @@
"""
Cache Entry Validator
Validates and repairs cache entries to prevent runtime errors from
missing or invalid critical fields.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import logging
import os
logger = logging.getLogger(__name__)
@dataclass
class ValidationResult:
"""Result of validating a single cache entry."""
is_valid: bool
repaired: bool
errors: List[str] = field(default_factory=list)
entry: Optional[Dict[str, Any]] = None
class CacheEntryValidator:
"""
Validates and repairs cache entry core fields.
Critical fields that cause runtime errors when missing:
- file_path: KeyError in multiple locations
- sha256: KeyError/AttributeError in hash operations
Medium severity fields that may cause sorting/display issues:
- size: KeyError during sorting
- modified: KeyError during sorting
- model_name: AttributeError on .lower() calls
Low severity fields:
- tags: KeyError/TypeError in recipe operations
"""
# Field definitions: (default_value, is_required)
CORE_FIELDS: Dict[str, Tuple[Any, bool]] = {
'file_path': ('', True),
'sha256': ('', True),
'file_name': ('', False),
'model_name': ('', False),
'folder': ('', False),
'size': (0, False),
'modified': (0.0, False),
'tags': ([], False),
'preview_url': ('', False),
'base_model': ('', False),
'from_civitai': (True, False),
'favorite': (False, False),
'exclude': (False, False),
'db_checked': (False, False),
'preview_nsfw_level': (0, False),
'notes': ('', False),
'usage_tips': ('', False),
'hash_status': ('completed', False),
}
@classmethod
def validate(cls, entry: Dict[str, Any], *, auto_repair: bool = True) -> ValidationResult:
"""
Validate a single cache entry.
Args:
entry: The cache entry dictionary to validate
auto_repair: If True, attempt to repair missing/invalid fields
Returns:
ValidationResult with validation status and optionally repaired entry
"""
if entry is None:
return ValidationResult(
is_valid=False,
repaired=False,
errors=['Entry is None'],
entry=None
)
if not isinstance(entry, dict):
return ValidationResult(
is_valid=False,
repaired=False,
errors=[f'Entry is not a dict: {type(entry).__name__}'],
entry=None
)
errors: List[str] = []
repaired = False
# If auto_repair is on, we work on a copy. If not, we still need a safe way to check fields.
working_entry = dict(entry) if auto_repair else entry
# Determine effective hash_status for validation logic
hash_status = entry.get('hash_status')
if hash_status is None:
if auto_repair:
working_entry['hash_status'] = 'completed'
repaired = True
hash_status = 'completed'
for field_name, (default_value, is_required) in cls.CORE_FIELDS.items():
# Get current value from the original entry to avoid side effects during validation
value = entry.get(field_name)
# Check if field is missing or None
if value is None:
# Special case: sha256 can be None/empty if hash_status is pending
if field_name == 'sha256' and hash_status == 'pending':
if auto_repair:
working_entry[field_name] = ''
repaired = True
continue
if is_required:
errors.append(f"Required field '{field_name}' is missing or None")
if auto_repair:
working_entry[field_name] = cls._get_default_copy(default_value)
repaired = True
continue
# Validate field type and value
field_error = cls._validate_field(field_name, value, default_value)
if field_error:
# Special case: allow empty string for sha256 if pending
if field_name == 'sha256' and hash_status == 'pending' and value == '':
continue
errors.append(field_error)
if auto_repair:
working_entry[field_name] = cls._get_default_copy(default_value)
repaired = True
# Special validation: file_path must not be empty for required field
file_path = working_entry.get('file_path', '')
if not file_path or (isinstance(file_path, str) and not file_path.strip()):
errors.append("Required field 'file_path' is empty")
# Cannot repair empty file_path - entry is invalid
return ValidationResult(
is_valid=False,
repaired=repaired,
errors=errors,
entry=working_entry if auto_repair else None
)
# Special validation: sha256 must not be empty for required field
# BUT allow empty sha256 when hash_status is pending (lazy hash calculation)
sha256 = working_entry.get('sha256', '')
# Use the effective hash_status we determined earlier
if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
# Allow empty sha256 for lazy hash calculation (checkpoints)
if hash_status != 'pending':
errors.append("Required field 'sha256' is empty")
# Cannot repair empty sha256 - entry is invalid
return ValidationResult(
is_valid=False,
repaired=repaired,
errors=errors,
entry=working_entry if auto_repair else None
)
# Normalize sha256 to lowercase if needed
if isinstance(sha256, str):
normalized_sha = sha256.lower().strip()
if normalized_sha != sha256:
if auto_repair:
working_entry['sha256'] = normalized_sha
repaired = True
else:
# If not auto-repairing, we don't consider case difference as a "critical error"
# that invalidates the entry, but we also don't mark it repaired.
pass
# Determine if entry is valid
# Entry is valid if no critical required field errors remain after repair
# Critical fields are file_path and sha256
CRITICAL_REQUIRED_FIELDS = {'file_path', 'sha256'}
has_critical_errors = any(
"Required field" in error and
any(f"'{field}'" in error for field in CRITICAL_REQUIRED_FIELDS)
for error in errors
)
is_valid = not has_critical_errors
return ValidationResult(
is_valid=is_valid,
repaired=repaired,
errors=errors,
entry=working_entry if auto_repair else entry
)
@classmethod
def validate_batch(
cls,
entries: List[Dict[str, Any]],
*,
auto_repair: bool = True
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
Validate a batch of cache entries.
Args:
entries: List of cache entry dictionaries to validate
auto_repair: If True, attempt to repair missing/invalid fields
Returns:
Tuple of (valid_entries, invalid_entries)
"""
if not entries:
return [], []
valid_entries: List[Dict[str, Any]] = []
invalid_entries: List[Dict[str, Any]] = []
for entry in entries:
result = cls.validate(entry, auto_repair=auto_repair)
if result.is_valid:
# Use repaired entry if available, otherwise original
valid_entries.append(result.entry if result.entry else entry)
else:
invalid_entries.append(entry)
# Log invalid entries for debugging
file_path = entry.get('file_path', '<unknown>') if isinstance(entry, dict) else '<not a dict>'
logger.warning(
f"Invalid cache entry for '{file_path}': {', '.join(result.errors)}"
)
return valid_entries, invalid_entries
@classmethod
def _validate_field(cls, field_name: str, value: Any, default_value: Any) -> Optional[str]:
"""
Validate a specific field value.
Returns an error message if invalid, None if valid.
"""
expected_type = type(default_value)
# Special handling for numeric types
if expected_type == int:
if not isinstance(value, (int, float)):
return f"Field '{field_name}' should be numeric, got {type(value).__name__}"
elif expected_type == float:
if not isinstance(value, (int, float)):
return f"Field '{field_name}' should be numeric, got {type(value).__name__}"
elif expected_type == bool:
# Be lenient with boolean fields - accept truthy/falsy values
pass
elif expected_type == str:
if not isinstance(value, str):
return f"Field '{field_name}' should be string, got {type(value).__name__}"
elif expected_type == list:
if not isinstance(value, (list, tuple)):
return f"Field '{field_name}' should be list, got {type(value).__name__}"
return None
@classmethod
def _get_default_copy(cls, default_value: Any) -> Any:
"""Get a copy of the default value to avoid shared mutable state."""
if isinstance(default_value, list):
return list(default_value)
if isinstance(default_value, dict):
return dict(default_value)
return default_value
@classmethod
def get_file_path_safe(cls, entry: Dict[str, Any], default: str = '') -> str:
"""Safely get file_path from an entry."""
if not isinstance(entry, dict):
return default
value = entry.get('file_path')
if isinstance(value, str):
return value
return default
@classmethod
def get_sha256_safe(cls, entry: Dict[str, Any], default: str = '') -> str:
"""Safely get sha256 from an entry."""
if not isinstance(entry, dict):
return default
value = entry.get('sha256')
if isinstance(value, str):
return value.lower()
return default

View File

@@ -1,201 +0,0 @@
"""
Cache Health Monitor
Monitors cache health status and determines when user intervention is needed.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional
import logging
from .cache_entry_validator import CacheEntryValidator, ValidationResult
logger = logging.getLogger(__name__)
class CacheHealthStatus(Enum):
"""Health status of the cache."""
HEALTHY = "healthy"
DEGRADED = "degraded"
CORRUPTED = "corrupted"
@dataclass
class HealthReport:
"""Report of cache health check."""
status: CacheHealthStatus
total_entries: int
valid_entries: int
invalid_entries: int
repaired_entries: int
invalid_paths: List[str] = field(default_factory=list)
message: str = ""
@property
def corruption_rate(self) -> float:
"""Calculate the percentage of invalid entries."""
if self.total_entries <= 0:
return 0.0
return self.invalid_entries / self.total_entries
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
'status': self.status.value,
'total_entries': self.total_entries,
'valid_entries': self.valid_entries,
'invalid_entries': self.invalid_entries,
'repaired_entries': self.repaired_entries,
'corruption_rate': f"{self.corruption_rate:.1%}",
'invalid_paths': self.invalid_paths[:10], # Limit to first 10
'message': self.message,
}
class CacheHealthMonitor:
"""
Monitors cache health and determines appropriate status.
Thresholds:
- HEALTHY: 0% invalid entries
- DEGRADED: 0-5% invalid entries (auto-repaired, user should rebuild)
- CORRUPTED: >5% invalid entries (significant data loss likely)
"""
# Threshold percentages
DEGRADED_THRESHOLD = 0.01 # 1% - show warning
CORRUPTED_THRESHOLD = 0.05 # 5% - critical warning
def __init__(
self,
*,
degraded_threshold: float = DEGRADED_THRESHOLD,
corrupted_threshold: float = CORRUPTED_THRESHOLD
):
"""
Initialize the health monitor.
Args:
degraded_threshold: Corruption rate threshold for DEGRADED status
corrupted_threshold: Corruption rate threshold for CORRUPTED status
"""
self.degraded_threshold = degraded_threshold
self.corrupted_threshold = corrupted_threshold
def check_health(
self,
entries: List[Dict[str, Any]],
*,
auto_repair: bool = True
) -> HealthReport:
"""
Check the health of cache entries.
Args:
entries: List of cache entry dictionaries to check
auto_repair: If True, attempt to repair entries during validation
Returns:
HealthReport with status and statistics
"""
if not entries:
return HealthReport(
status=CacheHealthStatus.HEALTHY,
total_entries=0,
valid_entries=0,
invalid_entries=0,
repaired_entries=0,
message="Cache is empty"
)
total_entries = len(entries)
valid_entries: List[Dict[str, Any]] = []
invalid_entries: List[Dict[str, Any]] = []
repaired_count = 0
invalid_paths: List[str] = []
for entry in entries:
result = CacheEntryValidator.validate(entry, auto_repair=auto_repair)
if result.is_valid:
valid_entries.append(result.entry if result.entry else entry)
if result.repaired:
repaired_count += 1
else:
invalid_entries.append(entry)
# Extract file path for reporting
file_path = CacheEntryValidator.get_file_path_safe(entry, '<unknown>')
invalid_paths.append(file_path)
invalid_count = len(invalid_entries)
valid_count = len(valid_entries)
# Determine status based on corruption rate
corruption_rate = invalid_count / total_entries if total_entries > 0 else 0.0
if invalid_count == 0:
status = CacheHealthStatus.HEALTHY
message = "Cache is healthy"
elif corruption_rate >= self.corrupted_threshold:
status = CacheHealthStatus.CORRUPTED
message = (
f"Cache is corrupted: {invalid_count} invalid entries "
f"({corruption_rate:.1%}). Rebuild recommended."
)
elif corruption_rate >= self.degraded_threshold or invalid_count > 0:
status = CacheHealthStatus.DEGRADED
message = (
f"Cache has {invalid_count} invalid entries "
f"({corruption_rate:.1%}). Consider rebuilding cache."
)
else:
# This shouldn't happen, but handle gracefully
status = CacheHealthStatus.HEALTHY
message = "Cache is healthy"
# Log the health check result
if status != CacheHealthStatus.HEALTHY:
logger.warning(
f"Cache health check: {status.value} - "
f"{invalid_count}/{total_entries} invalid, "
f"{repaired_count} repaired"
)
if invalid_paths:
logger.debug(f"Invalid entry paths: {invalid_paths[:5]}")
return HealthReport(
status=status,
total_entries=total_entries,
valid_entries=valid_count,
invalid_entries=invalid_count,
repaired_entries=repaired_count,
invalid_paths=invalid_paths,
message=message
)
def should_notify_user(self, report: HealthReport) -> bool:
"""
Determine if the user should be notified about cache health.
Args:
report: The health report to evaluate
Returns:
True if user should be notified
"""
return report.status != CacheHealthStatus.HEALTHY
def get_notification_severity(self, report: HealthReport) -> str:
"""
Get the severity level for user notification.
Args:
report: The health report to evaluate
Returns:
Severity string: 'warning' or 'error'
"""
if report.status == CacheHealthStatus.CORRUPTED:
return 'error'
return 'warning'

View File

@@ -1,299 +1,37 @@
import json
import logging import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from ..utils.models import CheckpointMetadata from ..utils.models import CheckpointMetadata
from ..utils.file_utils import find_preview_file, normalize_path
from ..utils.metadata_manager import MetadataManager
from ..config import config from ..config import config
from .model_scanner import ModelScanner from .model_scanner import ModelScanner
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CheckpointScanner(ModelScanner): class CheckpointScanner(ModelScanner):
"""Service for scanning and managing checkpoint files""" """Service for scanning and managing checkpoint files"""
def __init__(self): def __init__(self):
# Define supported file extensions # Define supported file extensions
file_extensions = { file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.gguf'}
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".safetensors",
".pkl",
".sft",
".gguf",
}
super().__init__( super().__init__(
model_type="checkpoint", model_type="checkpoint",
model_class=CheckpointMetadata, model_class=CheckpointMetadata,
file_extensions=file_extensions, file_extensions=file_extensions,
hash_index=ModelHashIndex(), hash_index=ModelHashIndex()
) )
async def _create_default_metadata(
self, file_path: str
) -> Optional[CheckpointMetadata]:
"""Create default metadata for checkpoint without calculating hash (lazy hash).
Checkpoints are typically large (10GB+), so we skip hash calculation during initial
scanning to improve startup performance. Hash will be calculated on-demand when
fetching metadata from Civitai.
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found: {file_path}")
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
# Create metadata WITHOUT calculating hash
metadata = CheckpointMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256="", # Empty hash - will be calculated on-demand
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type="checkpoint",
from_civitai=False, # Mark as local model since no hash yet
hash_status="pending", # Mark hash as pending
)
# Save the created metadata
logger.info(f"Creating checkpoint metadata (hash pending) for {file_path}")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(
f"Error creating default checkpoint metadata for {file_path}: {e}"
)
return None
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
"""Calculate hash for a checkpoint on-demand.
Args:
file_path: Path to the model file
Returns:
SHA256 hash string, or None if calculation failed
"""
from ..utils.file_utils import calculate_sha256
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found for hash calculation: {file_path}")
return None
# Load current metadata
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata is None:
logger.error(f"No metadata found for {file_path}")
return None
# Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256:
return metadata.sha256
# Update status to calculating
metadata.hash_status = "calculating"
await MetadataManager.save_metadata(file_path, metadata)
# Calculate hash
logger.info(f"Calculating hash for checkpoint: {file_path}")
sha256 = await calculate_sha256(real_path)
# Update metadata with hash
metadata.sha256 = sha256
metadata.hash_status = "completed"
await MetadataManager.save_metadata(file_path, metadata)
# Update hash index
self._hash_index.add_entry(sha256.lower(), file_path)
logger.info(f"Hash calculated for checkpoint: {file_path}")
return sha256
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
# Update status to failed
try:
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata:
metadata.hash_status = "failed"
await MetadataManager.save_metadata(file_path, metadata)
except Exception:
pass
return None
async def calculate_all_pending_hashes(
self, progress_callback=None
) -> Dict[str, int]:
"""Calculate hashes for all checkpoints with pending hash status.
If cache is not initialized, scans filesystem directly for metadata files
with hash_status != 'completed'.
Args:
progress_callback: Optional callback(progress, total, current_file)
Returns:
Dict with 'completed', 'failed', 'total' counts
"""
# Try to get from cache first
cache = await self.get_cached_data()
if cache and cache.raw_data:
# Use cache if available
pending_models = [
item
for item in cache.raw_data
if item.get("hash_status") != "completed" or not item.get("sha256")
]
else:
# Cache not initialized, scan filesystem directly
pending_models = await self._find_pending_models_from_filesystem()
if not pending_models:
return {"completed": 0, "failed": 0, "total": 0}
total = len(pending_models)
completed = 0
failed = 0
for i, model_data in enumerate(pending_models):
file_path = model_data.get("file_path")
if not file_path:
continue
try:
sha256 = await self.calculate_hash_for_model(file_path)
if sha256:
completed += 1
else:
failed += 1
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
failed += 1
if progress_callback:
try:
await progress_callback(i + 1, total, file_path)
except Exception:
pass
return {"completed": completed, "failed": failed, "total": total}
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for checkpoint metadata files with pending hash status."""
pending_models = []
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
for dirpath, _dirnames, filenames in os.walk(root_path):
for filename in filenames:
if not filename.endswith(".metadata.json"):
continue
metadata_path = os.path.join(dirpath, filename)
try:
with open(metadata_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Check if hash is pending
hash_status = data.get("hash_status", "completed")
sha256 = data.get("sha256", "")
if hash_status != "completed" or not sha256:
# Find corresponding model file
model_name = filename.replace(".metadata.json", "")
model_path = None
# Look for model file with matching name
for ext in self.file_extensions:
potential_path = os.path.join(dirpath, model_name + ext)
if os.path.exists(potential_path):
model_path = potential_path
break
if model_path:
pending_models.append(
{
"file_path": model_path.replace(os.sep, "/"),
"hash_status": hash_status,
"sha256": sha256,
**{
k: v
for k, v in data.items()
if k
not in [
"file_path",
"hash_status",
"sha256",
]
},
}
)
except (json.JSONDecodeError, Exception) as e:
logger.debug(
f"Error reading metadata file {metadata_path}: {e}"
)
continue
return pending_models
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]: def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
"""Resolve the sub-type based on the root path. """Resolve the sub-type based on the root path."""
Checks both standard ComfyUI paths and LoRA Manager's extra folder paths.
"""
if not root_path: if not root_path:
return None return None
# Check standard ComfyUI checkpoint paths
if config.checkpoints_roots and root_path in config.checkpoints_roots: if config.checkpoints_roots and root_path in config.checkpoints_roots:
return "checkpoint" return "checkpoint"
# Check extra checkpoint paths
if (
config.extra_checkpoints_roots
and root_path in config.extra_checkpoints_roots
):
return "checkpoint"
# Check standard ComfyUI unet paths
if config.unet_roots and root_path in config.unet_roots: if config.unet_roots and root_path in config.unet_roots:
return "diffusion_model" return "diffusion_model"
# Check extra unet paths
if config.extra_unet_roots and root_path in config.extra_unet_roots:
return "diffusion_model"
return None return None
def adjust_metadata(self, metadata, file_path, root_path): def adjust_metadata(self, metadata, file_path, root_path):
@@ -313,16 +51,5 @@ class CheckpointScanner(ModelScanner):
return entry return entry
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get checkpoint root directories (including extra paths)""" """Get checkpoint root directories"""
roots: List[str] = [] return config.base_models_roots
roots.extend(config.base_models_roots or [])
roots.extend(config.extra_checkpoints_roots or [])
roots.extend(config.extra_unet_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots

View File

@@ -43,7 +43,6 @@ class CheckpointService(BaseModelService):
"sub_type": sub_type, "sub_type": sub_type,
"favorite": checkpoint_data.get("favorite", False), "favorite": checkpoint_data.get("favorite", False),
"update_available": bool(checkpoint_data.get("update_available", False)), "update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
} }

View File

@@ -3,17 +3,13 @@ import copy
import logging import logging
import os import os
from typing import Any, Optional, Dict, Tuple, List, Sequence from typing import Any, Optional, Dict, Tuple, List, Sequence
from .model_metadata_provider import ( from .model_metadata_provider import CivitaiModelMetadataProvider, ModelMetadataProviderManager
CivitaiModelMetadataProvider,
ModelMetadataProviderManager,
)
from .downloader import get_downloader from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload from ..utils.civitai_utils import resolve_license_payload
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CivitaiClient: class CivitaiClient:
_instance = None _instance = None
_lock = asyncio.Lock() _lock = asyncio.Lock()
@@ -27,15 +23,13 @@ class CivitaiClient:
# Register this client as a metadata provider # Register this client as a metadata provider
provider_manager = await ModelMetadataProviderManager.get_instance() provider_manager = await ModelMetadataProviderManager.get_instance()
provider_manager.register_provider( provider_manager.register_provider('civitai', CivitaiModelMetadataProvider(cls._instance), True)
"civitai", CivitaiModelMetadataProvider(cls._instance), True
)
return cls._instance return cls._instance
def __init__(self): def __init__(self):
# Check if already initialized for singleton pattern # Check if already initialized for singleton pattern
if hasattr(self, "_initialized"): if hasattr(self, '_initialized'):
return return
self._initialized = True self._initialized = True
@@ -82,9 +76,7 @@ class CivitaiClient:
if isinstance(meta, dict) and "comfy" in meta: if isinstance(meta, dict) and "comfy" in meta:
meta.pop("comfy", None) meta.pop("comfy", None)
async def download_file( async def download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
self, url: str, save_dir: str, default_filename: str, progress_callback=None
) -> Tuple[bool, str]:
"""Download file with resumable downloads and retry mechanism """Download file with resumable downloads and retry mechanism
Args: Args:
@@ -105,32 +97,27 @@ class CivitaiClient:
save_path=save_path, save_path=save_path,
progress_callback=progress_callback, progress_callback=progress_callback,
use_auth=True, # Enable CivitAI authentication use_auth=True, # Enable CivitAI authentication
allow_resume=True, allow_resume=True
) )
return success, result return success, result
async def get_model_by_hash( async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
self, model_hash: str
) -> Tuple[Optional[Dict], Optional[str]]:
try: try:
success, version = await self._make_request( success, version = await self._make_request(
"GET", 'GET',
f"{self.base_url}/model-versions/by-hash/{model_hash}", f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True, use_auth=True
) )
if not success: if not success:
message = str(version) message = str(version)
if "not found" in message.lower(): if "not found" in message.lower():
return None, "Model not found" return None, "Model not found"
logger.error( logger.error("Failed to fetch model info for %s: %s", model_hash[:10], message)
"Failed to fetch model info for %s: %s", model_hash[:10], message
)
return None, message return None, message
if isinstance(version, dict): model_id = version.get('modelId')
model_id = version.get("modelId")
if model_id: if model_id:
model_data = await self._fetch_model_data(model_id) model_data = await self._fetch_model_data(model_id)
if model_data: if model_data:
@@ -138,8 +125,6 @@ class CivitaiClient:
self._remove_comfy_metadata(version) self._remove_comfy_metadata(version)
return version, None return version, None
else:
return None, "Invalid response format"
except RateLimitError: except RateLimitError:
raise raise
except Exception as exc: except Exception as exc:
@@ -151,12 +136,12 @@ class CivitaiClient:
downloader = await get_downloader() downloader = await get_downloader()
success, content, headers = await downloader.download_to_memory( success, content, headers = await downloader.download_to_memory(
image_url, image_url,
use_auth=False, # Preview images don't need auth use_auth=False # Preview images don't need auth
) )
if success: if success:
# Ensure directory exists # Ensure directory exists
os.makedirs(os.path.dirname(save_path), exist_ok=True) os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, "wb") as f: with open(save_path, 'wb') as f:
f.write(content) f.write(content)
return True return True
return False return False
@@ -190,17 +175,19 @@ class CivitaiClient:
"""Get all versions of a model with local availability info""" """Get all versions of a model with local availability info"""
try: try:
success, result = await self._make_request( success, result = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True 'GET',
f"{self.base_url}/models/{model_id}",
use_auth=True
) )
if success: if success:
# Also return model type along with versions # Also return model type along with versions
return { return {
"modelVersions": result.get("modelVersions", []), 'modelVersions': result.get('modelVersions', []),
"type": result.get("type", ""), 'type': result.get('type', ''),
"name": result.get("name", ""), 'name': result.get('name', '')
} }
message = self._extract_error_message(result) message = self._extract_error_message(result)
if message and "not found" in message.lower(): if message and 'not found' in message.lower():
raise ResourceNotFoundError(f"Resource not found for model {model_id}") raise ResourceNotFoundError(f"Resource not found for model {model_id}")
if message: if message:
raise RuntimeError(message) raise RuntimeError(message)
@@ -234,15 +221,15 @@ class CivitaiClient:
try: try:
query = ",".join(normalized_ids) query = ",".join(normalized_ids)
success, result = await self._make_request( success, result = await self._make_request(
"GET", 'GET',
f"{self.base_url}/models", f"{self.base_url}/models",
use_auth=True, use_auth=True,
params={"ids": query}, params={'ids': query},
) )
if not success: if not success:
return None return None
items = result.get("items") if isinstance(result, dict) else None items = result.get('items') if isinstance(result, dict) else None
if not isinstance(items, list): if not isinstance(items, list):
return {} return {}
@@ -250,19 +237,19 @@ class CivitaiClient:
for item in items: for item in items:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
model_id = item.get("id") model_id = item.get('id')
try: try:
normalized_id = int(model_id) normalized_id = int(model_id)
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
payload[normalized_id] = { payload[normalized_id] = {
"modelVersions": item.get("modelVersions", []), 'modelVersions': item.get('modelVersions', []),
"type": item.get("type", ""), 'type': item.get('type', ''),
"name": item.get("name", ""), 'name': item.get('name', ''),
"allowNoCredit": item.get("allowNoCredit"), 'allowNoCredit': item.get('allowNoCredit'),
"allowCommercialUse": item.get("allowCommercialUse"), 'allowCommercialUse': item.get('allowCommercialUse'),
"allowDerivatives": item.get("allowDerivatives"), 'allowDerivatives': item.get('allowDerivatives'),
"allowDifferentLicense": item.get("allowDifferentLicense"), 'allowDifferentLicense': item.get('allowDifferentLicense'),
} }
return payload return payload
except RateLimitError: except RateLimitError:
@@ -271,9 +258,7 @@ class CivitaiClient:
logger.error(f"Error fetching model versions in bulk: {exc}") logger.error(f"Error fetching model versions in bulk: {exc}")
return None return None
async def get_model_version( async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
self, model_id: int = None, version_id: int = None
) -> Optional[Dict]:
"""Get specific model version with additional metadata.""" """Get specific model version with additional metadata."""
try: try:
if model_id is None and version_id is not None: if model_id is None and version_id is not None:
@@ -296,7 +281,7 @@ class CivitaiClient:
if version is None: if version is None:
return None return None
model_id = version.get("modelId") model_id = version.get('modelId')
if not model_id: if not model_id:
logger.error(f"No modelId found in version {version_id}") logger.error(f"No modelId found in version {version_id}")
return None return None
@@ -308,9 +293,7 @@ class CivitaiClient:
self._remove_comfy_metadata(version) self._remove_comfy_metadata(version)
return version return version
async def _get_version_with_model_id( async def _get_version_with_model_id(self, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
self, model_id: int, version_id: Optional[int]
) -> Optional[Dict]:
model_data = await self._fetch_model_data(model_id) model_data = await self._fetch_model_data(model_id)
if not model_data: if not model_data:
return None return None
@@ -319,12 +302,8 @@ class CivitaiClient:
if target_version is None: if target_version is None:
return None return None
target_version_id = target_version.get("id") target_version_id = target_version.get('id')
version = ( version = await self._fetch_version_by_id(target_version_id) if target_version_id else None
await self._fetch_version_by_id(target_version_id)
if target_version_id
else None
)
if version is None: if version is None:
model_hash = self._extract_primary_model_hash(target_version) model_hash = self._extract_primary_model_hash(target_version)
@@ -336,9 +315,7 @@ class CivitaiClient:
) )
if version is None: if version is None:
version = self._build_version_from_model_data( version = self._build_version_from_model_data(target_version, model_id, model_data)
target_version, model_id, model_data
)
self._enrich_version_with_model_data(version, model_data) self._enrich_version_with_model_data(version, model_data)
self._remove_comfy_metadata(version) self._remove_comfy_metadata(version)
@@ -346,7 +323,9 @@ class CivitaiClient:
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]: async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
success, data = await self._make_request( success, data = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True 'GET',
f"{self.base_url}/models/{model_id}",
use_auth=True
) )
if success: if success:
return data return data
@@ -358,7 +337,9 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True 'GET',
f"{self.base_url}/model-versions/{version_id}",
use_auth=True
) )
if success: if success:
return version return version
@@ -371,7 +352,9 @@ class CivitaiClient:
return None return None
success, version = await self._make_request( success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True 'GET',
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True
) )
if success: if success:
return version return version
@@ -379,17 +362,16 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by hash {model_hash}") logger.warning(f"Failed to fetch version by hash {model_hash}")
return None return None
def _select_target_version( def _select_target_version(self, model_data: Dict, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
self, model_data: Dict, model_id: int, version_id: Optional[int] model_versions = model_data.get('modelVersions', [])
) -> Optional[Dict]:
model_versions = model_data.get("modelVersions", [])
if not model_versions: if not model_versions:
logger.warning(f"No model versions found for model {model_id}") logger.warning(f"No model versions found for model {model_id}")
return None return None
if version_id is not None: if version_id is not None:
target_version = next( target_version = next(
(item for item in model_versions if item.get("id") == version_id), None (item for item in model_versions if item.get('id') == version_id),
None
) )
if target_version is None: if target_version is None:
logger.warning( logger.warning(
@@ -401,45 +383,41 @@ class CivitaiClient:
return model_versions[0] return model_versions[0]
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]: def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
for file_info in version_entry.get("files", []): for file_info in version_entry.get('files', []):
if file_info.get("type") == "Model" and file_info.get("primary"): if file_info.get('type') == 'Model' and file_info.get('primary'):
hashes = file_info.get("hashes", {}) hashes = file_info.get('hashes', {})
model_hash = hashes.get("SHA256") model_hash = hashes.get('SHA256')
if model_hash: if model_hash:
return model_hash return model_hash
return None return None
def _build_version_from_model_data( def _build_version_from_model_data(self, version_entry: Dict, model_id: int, model_data: Dict) -> Dict:
self, version_entry: Dict, model_id: int, model_data: Dict
) -> Dict:
version = copy.deepcopy(version_entry) version = copy.deepcopy(version_entry)
version.pop("index", None) version.pop('index', None)
version["modelId"] = model_id version['modelId'] = model_id
version["model"] = { version['model'] = {
"name": model_data.get("name"), 'name': model_data.get('name'),
"type": model_data.get("type"), 'type': model_data.get('type'),
"nsfw": model_data.get("nsfw"), 'nsfw': model_data.get('nsfw'),
"poi": model_data.get("poi"), 'poi': model_data.get('poi')
} }
return version return version
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None: def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None:
model_info = version.get("model") model_info = version.get('model')
if not isinstance(model_info, dict): if not isinstance(model_info, dict):
model_info = {} model_info = {}
version["model"] = model_info version['model'] = model_info
model_info["description"] = model_data.get("description") model_info['description'] = model_data.get("description")
model_info["tags"] = model_data.get("tags", []) model_info['tags'] = model_data.get("tags", [])
version["creator"] = model_data.get("creator") version['creator'] = model_data.get("creator")
license_payload = resolve_license_payload(model_data) license_payload = resolve_license_payload(model_data)
for field, value in license_payload.items(): for field, value in license_payload.items():
model_info[field] = value model_info[field] = value
async def get_model_version_info( async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
self, version_id: str
) -> Tuple[Optional[Dict], Optional[str]]:
"""Fetch model version metadata from Civitai """Fetch model version metadata from Civitai
Args: Args:
@@ -454,12 +432,14 @@ class CivitaiClient:
url = f"{self.base_url}/model-versions/{version_id}" url = f"{self.base_url}/model-versions/{version_id}"
logger.debug(f"Resolving DNS for model version info: {url}") logger.debug(f"Resolving DNS for model version info: {url}")
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request(
'GET',
url,
use_auth=True
)
if success: if success:
logger.debug( logger.debug(f"Successfully fetched model version info for: {version_id}")
f"Successfully fetched model version info for: {version_id}"
)
self._remove_comfy_metadata(result) self._remove_comfy_metadata(result)
return result, None return result, None
@@ -492,7 +472,11 @@ class CivitaiClient:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X" url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
logger.debug(f"Fetching image info for ID: {image_id}") logger.debug(f"Fetching image info for ID: {image_id}")
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request(
'GET',
url,
use_auth=True
)
if success: if success:
if result and "items" in result and len(result["items"]) > 0: if result and "items" in result and len(result["items"]) > 0:
@@ -517,7 +501,11 @@ class CivitaiClient:
try: try:
url = f"{self.base_url}/models?username={username}" url = f"{self.base_url}/models?username={username}"
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request(
'GET',
url,
use_auth=True
)
if not success: if not success:
logger.error("Failed to fetch models for %s: %s", username, result) logger.error("Failed to fetch models for %s: %s", username, result)

View File

@@ -49,7 +49,6 @@ class CustomWordsService:
if self._tag_index is None: if self._tag_index is None:
try: try:
from .tag_fts_index import get_tag_fts_index from .tag_fts_index import get_tag_fts_index
self._tag_index = get_tag_fts_index() self._tag_index = get_tag_fts_index()
except Exception as e: except Exception as e:
logger.warning(f"Failed to initialize TagFTSIndex: {e}") logger.warning(f"Failed to initialize TagFTSIndex: {e}")
@@ -60,16 +59,14 @@ class CustomWordsService:
self, self,
search_term: str, search_term: str,
limit: int = 20, limit: int = 20,
offset: int = 0,
categories: Optional[List[int]] = None, categories: Optional[List[int]] = None,
enriched: bool = False, enriched: bool = False
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Search tags using TagFTSIndex with category filtering. """Search tags using TagFTSIndex with category filtering.
Args: Args:
search_term: The search term to match against. search_term: The search term to match against.
limit: Maximum number of results to return. limit: Maximum number of results to return.
offset: Number of results to skip.
categories: Optional list of category IDs to filter by. categories: Optional list of category IDs to filter by.
enriched: If True, always return enriched results with category enriched: If True, always return enriched results with category
and post_count (default behavior now). and post_count (default behavior now).
@@ -79,9 +76,7 @@ class CustomWordsService:
""" """
tag_index = self._get_tag_index() tag_index = self._get_tag_index()
if tag_index is not None: if tag_index is not None:
results = tag_index.search( results = tag_index.search(search_term, categories=categories, limit=limit)
search_term, categories=categories, limit=limit, offset=offset
)
return results return results
logger.debug("TagFTSIndex not available, returning empty results") logger.debug("TagFTSIndex not available, returning empty results")

View File

@@ -86,7 +86,6 @@ class DownloadCoordinator:
progress_callback=progress_callback, progress_callback=progress_callback,
download_id=download_id, download_id=download_id,
source=payload.get("source"), source=payload.get("source"),
file_params=payload.get("file_params"),
) )
result["download_id"] = download_id result["download_id"] = download_id

View File

@@ -9,12 +9,8 @@ from collections import OrderedDict
import uuid import uuid
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata, MiscMetadata
from ..utils.constants import ( from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
@@ -64,6 +60,10 @@ class DownloadManager:
"""Get the checkpoint scanner from registry""" """Get the checkpoint scanner from registry"""
return await ServiceRegistry.get_checkpoint_scanner() return await ServiceRegistry.get_checkpoint_scanner()
async def _get_misc_scanner(self):
"""Get the misc scanner from registry"""
return await ServiceRegistry.get_misc_scanner()
async def download_from_civitai( async def download_from_civitai(
self, self,
model_id: int = None, model_id: int = None,
@@ -74,7 +74,6 @@ class DownloadManager:
use_default_paths: bool = False, use_default_paths: bool = False,
download_id: str = None, download_id: str = None,
source: str = None, source: str = None,
file_params: Dict = None,
) -> Dict: ) -> Dict:
"""Download model from Civitai with task tracking and concurrency control """Download model from Civitai with task tracking and concurrency control
@@ -87,7 +86,6 @@ class DownloadManager:
use_default_paths: Flag to use default paths use_default_paths: Flag to use default paths
download_id: Unique identifier for this download task download_id: Unique identifier for this download task
source: Optional source parameter to specify metadata provider source: Optional source parameter to specify metadata provider
file_params: Optional dict with file selection params (type, format, size, fp, isPrimary)
Returns: Returns:
Dict with download result Dict with download result
@@ -128,7 +126,6 @@ class DownloadManager:
progress_callback, progress_callback,
use_default_paths, use_default_paths,
source, source,
file_params,
) )
) )
@@ -162,7 +159,6 @@ class DownloadManager:
progress_callback=None, progress_callback=None,
use_default_paths: bool = False, use_default_paths: bool = False,
source: str = None, source: str = None,
file_params: Dict = None,
): ):
"""Execute download with semaphore to limit concurrency""" """Execute download with semaphore to limit concurrency"""
# Update status to waiting # Update status to waiting
@@ -223,7 +219,6 @@ class DownloadManager:
use_default_paths, use_default_paths,
task_id, task_id,
source, source,
file_params,
) )
# Update status based on result # Update status based on result
@@ -275,7 +270,6 @@ class DownloadManager:
use_default_paths, use_default_paths,
download_id=None, download_id=None,
source=None, source=None,
file_params=None,
): ):
"""Wrapper for original download_from_civitai implementation""" """Wrapper for original download_from_civitai implementation"""
try: try:
@@ -285,6 +279,7 @@ class DownloadManager:
lora_scanner = await self._get_lora_scanner() lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner() checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner()
misc_scanner = await self._get_misc_scanner()
# Check lora scanner first # Check lora scanner first
if await lora_scanner.check_model_version_exists(model_version_id): if await lora_scanner.check_model_version_exists(model_version_id):
@@ -309,6 +304,13 @@ class DownloadManager:
"error": "Model version already exists in embedding library", "error": "Model version already exists in embedding library",
} }
# Check misc scanner (VAE, Upscaler)
if await misc_scanner.check_model_version_exists(model_version_id):
return {
"success": False,
"error": "Model version already exists in misc library",
}
# Use CivArchive provider directly when source is 'civarchive' # Use CivArchive provider directly when source is 'civarchive'
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai # This prioritizes CivArchive metadata (with mirror availability info) over Civitai
if source == "civarchive": if source == "civarchive":
@@ -347,6 +349,10 @@ class DownloadManager:
model_type = "lora" model_type = "lora"
elif model_type_from_info == "textualinversion": elif model_type_from_info == "textualinversion":
model_type = "embedding" model_type = "embedding"
elif model_type_from_info == "vae":
model_type = "misc"
elif model_type_from_info == "upscaler":
model_type = "misc"
else: else:
return { return {
"success": False, "success": False,
@@ -356,12 +362,10 @@ class DownloadManager:
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
base_model_value = version_info.get("baseModel", "") base_model_value = version_info.get('baseModel', '')
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info( logger.info(f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder")
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# Case 2: model_version_id was None, check after getting version_info # Case 2: model_version_id was None, check after getting version_info
if model_version_id is None: if model_version_id is None:
@@ -391,6 +395,14 @@ class DownloadManager:
"success": False, "success": False,
"error": "Model version already exists in embedding library", "error": "Model version already exists in embedding library",
} }
elif model_type == "misc":
# Check misc scanner (VAE, Upscaler)
misc_scanner = await self._get_misc_scanner()
if await misc_scanner.check_model_version_exists(version_id):
return {
"success": False,
"error": "Model version already exists in misc library",
}
# Handle use_default_paths # Handle use_default_paths
if use_default_paths: if use_default_paths:
@@ -425,6 +437,26 @@ class DownloadManager:
"error": "Default embedding root path not set in settings", "error": "Default embedding root path not set in settings",
} }
save_dir = default_path save_dir = default_path
elif model_type == "misc":
from ..config import config
civitai_type = version_info.get("model", {}).get("type", "").lower()
if civitai_type == "vae":
default_paths = config.vae_roots
error_msg = "VAE root path not configured"
elif civitai_type == "upscaler":
default_paths = config.upscaler_roots
error_msg = "Upscaler root path not configured"
else:
default_paths = config.misc_roots
error_msg = "Misc root path not configured"
if not default_paths:
return {
"success": False,
"error": error_msg,
}
save_dir = default_paths[0] if default_paths else ""
# Calculate relative path using template # Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type) relative_path = self._calculate_relative_path(version_info, model_type)
@@ -468,62 +500,16 @@ class DownloadManager:
await progress_callback(0) await progress_callback(0)
# 2. Get file information # 2. Get file information
files = version_info.get("files", [])
file_info = None
# If file_params is provided, try to find matching file
if file_params and model_version_id:
target_type = file_params.get("type", "Model")
target_format = file_params.get("format", "SafeTensor")
target_size = file_params.get("size", "full")
target_fp = file_params.get("fp")
is_primary = file_params.get("isPrimary", False)
if is_primary:
# Find primary file
file_info = next( file_info = next(
( (
f f
for f in files for f in version_info.get("files", [])
if f.get("primary")
and f.get("type") in ("Model", "Negative")
),
None,
)
else:
# Match by metadata
for f in files:
f_type = f.get("type", "")
f_meta = f.get("metadata", {})
# Check type match
if f_type != target_type:
continue
# Check metadata match
if f_meta.get("format") != target_format:
continue
if f_meta.get("size") != target_size:
continue
if target_fp and f_meta.get("fp") != target_fp:
continue
file_info = f
break
# Fallback to primary file if no match found
if not file_info:
file_info = next(
(
f
for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative") if f.get("primary") and f.get("type") in ("Model", "Negative")
), ),
None, None,
) )
if not file_info: if not file_info:
return {"success": False, "error": "No suitable file found in metadata"} return {"success": False, "error": "No primary file found in metadata"}
mirrors = file_info.get("mirrors") or [] mirrors = file_info.get("mirrors") or []
download_urls = [] download_urls = []
if mirrors: if mirrors:
@@ -554,9 +540,7 @@ class DownloadManager:
return {"success": False, "error": "No mirror URL found"} return {"success": False, "error": "No mirror URL found"}
# 3. Prepare download # 3. Prepare download
file_name = file_info.get("name", "") file_name = file_info["name"]
if not file_name:
return {"success": False, "error": "No filename found in file info"}
save_path = os.path.join(save_dir, file_name) save_path = os.path.join(save_dir, file_name)
# 5. Prepare metadata based on model type # 5. Prepare metadata based on model type
@@ -575,6 +559,11 @@ class DownloadManager:
version_info, file_info, save_path version_info, file_info, save_path
) )
logger.info(f"Creating EmbeddingMetadata for {file_name}") logger.info(f"Creating EmbeddingMetadata for {file_name}")
elif model_type == "misc":
metadata = MiscMetadata.from_civitai_info(
version_info, file_info, save_path
)
logger.info(f"Creating MiscMetadata for {file_name}")
# 6. Start download process # 6. Start download process
result = await self._execute_download( result = await self._execute_download(
@@ -680,6 +669,8 @@ class DownloadManager:
scanner = await self._get_checkpoint_scanner() scanner = await self._get_checkpoint_scanner()
elif model_type == "embedding": elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner() scanner = await ServiceRegistry.get_embedding_scanner()
elif model_type == "misc":
scanner = await self._get_misc_scanner()
except Exception as exc: except Exception as exc:
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc) logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
@@ -1076,6 +1067,9 @@ class DownloadManager:
elif model_type == "embedding": elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner() scanner = await ServiceRegistry.get_embedding_scanner()
logger.info(f"Updating embedding cache for {actual_file_paths[0]}") logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
elif model_type == "misc":
scanner = await self._get_misc_scanner()
logger.info(f"Updating misc cache for {actual_file_paths[0]}")
adjust_cached_entry = ( adjust_cached_entry = (
getattr(scanner, "adjust_cached_entry", None) getattr(scanner, "adjust_cached_entry", None)
@@ -1185,6 +1179,14 @@ class DownloadManager:
".pkl", ".pkl",
".sft", ".sft",
} }
if model_type == "misc":
return {
".ckpt",
".pt",
".bin",
".pth",
".safetensors",
}
return {".safetensors"} return {".safetensors"}
async def _extract_model_files_from_archive( async def _extract_model_files_from_archive(
@@ -1231,13 +1233,7 @@ class DownloadManager:
entries: List = [] entries: List = []
for index, file_path in enumerate(file_paths): for index, file_path in enumerate(file_paths):
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata) entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
# Update file paths without modifying size and modified timestamps entry.update_file_info(file_path)
# modified should remain as the download start time (import time)
# size will be updated below to reflect actual downloaded file size
entry.file_path = file_path.replace(os.sep, "/")
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size
entry.size = os.path.getsize(file_path)
entry.sha256 = await calculate_sha256(file_path) entry.sha256 = await calculate_sha256(file_path)
entries.append(entry) entries.append(entry)

View File

@@ -22,15 +22,5 @@ class EmbeddingScanner(ModelScanner):
) )
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get embedding root directories (including extra paths)""" """Get embedding root directories"""
roots: List[str] = [] return config.embeddings_roots
roots.extend(config.embeddings_roots or [])
roots.extend(config.extra_embeddings_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots

View File

@@ -43,7 +43,6 @@ class EmbeddingService(BaseModelService):
"sub_type": sub_type, "sub_type": sub_type,
"favorite": embedding_data.get("favorite", False), "favorite": embedding_data.get("favorite", False),
"update_available": bool(embedding_data.get("update_available", False)), "update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
} }

View File

@@ -25,51 +25,41 @@ class LoraScanner(ModelScanner):
) )
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get lora root directories (including extra paths)""" """Get lora root directories"""
roots: List[str] = [] return config.loras_roots
roots.extend(config.loras_roots or [])
roots.extend(config.extra_loras_roots or [])
# Remove duplicates while preserving order
seen: set = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots
async def diagnose_hash_index(self): async def diagnose_hash_index(self):
"""Diagnostic method to verify hash index functionality""" """Diagnostic method to verify hash index functionality"""
logger.debug("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n") print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr)
# First check if the hash index has any entries # First check if the hash index has any entries
if hasattr(self, '_hash_index'): if hasattr(self, '_hash_index'):
index_entries = len(self._hash_index._hash_to_path) index_entries = len(self._hash_index._hash_to_path)
logger.debug(f"Hash index has {index_entries} entries") print(f"Hash index has {index_entries} entries", file=sys.stderr)
# Print a few example entries if available # Print a few example entries if available
if index_entries > 0: if index_entries > 0:
logger.debug("\nSample hash index entries:") print("\nSample hash index entries:", file=sys.stderr)
count = 0 count = 0
for hash_val, path in self._hash_index._hash_to_path.items(): for hash_val, path in self._hash_index._hash_to_path.items():
if count < 5: # Just show the first 5 if count < 5: # Just show the first 5
logger.debug(f"Hash: {hash_val[:8]}... -> Path: {path}") print(f"Hash: {hash_val[:8]}... -> Path: {path}", file=sys.stderr)
count += 1 count += 1
else: else:
break break
else: else:
logger.debug("Hash index not initialized") print("Hash index not initialized", file=sys.stderr)
# Try looking up by a known hash for testing # Try looking up by a known hash for testing
if not hasattr(self, '_hash_index') or not self._hash_index._hash_to_path: if not hasattr(self, '_hash_index') or not self._hash_index._hash_to_path:
logger.debug("No hash entries to test lookup with") print("No hash entries to test lookup with", file=sys.stderr)
return return
test_hash = next(iter(self._hash_index._hash_to_path.keys())) test_hash = next(iter(self._hash_index._hash_to_path.keys()))
test_path = self._hash_index.get_path(test_hash) test_path = self._hash_index.get_path(test_hash)
logger.debug(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}") print(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}", file=sys.stderr)
# Also test reverse lookup # Also test reverse lookup
test_hash_result = self._hash_index.get_hash(test_path) test_hash_result = self._hash_index.get_hash(test_path)
logger.debug(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n") print(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n", file=sys.stderr)

View File

@@ -48,9 +48,6 @@ class LoraService(BaseModelService):
"notes": lora_data.get("notes", ""), "notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False), "favorite": lora_data.get("favorite", False),
"update_available": bool(lora_data.get("update_available", False)), "update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False)
),
"sub_type": sub_type, "sub_type": sub_type,
"civitai": self.filter_civitai_data( "civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True lora_data.get("civitai", {}), minimal=True
@@ -64,68 +61,6 @@ class LoraService(BaseModelService):
if first_letter: if first_letter:
data = self._filter_by_first_letter(data, first_letter) data = self._filter_by_first_letter(data, first_letter)
# Handle name pattern filters
name_pattern_include = kwargs.get("name_pattern_include", [])
name_pattern_exclude = kwargs.get("name_pattern_exclude", [])
name_pattern_use_regex = kwargs.get("name_pattern_use_regex", False)
if name_pattern_include or name_pattern_exclude:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in data:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if name_pattern_exclude:
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_exclude, name_pattern_use_regex
):
excluded = True
break
if excluded:
continue
# Check include patterns
if name_pattern_include:
included = False
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_include, name_pattern_use_regex
):
included = True
break
if not included:
continue
filtered.append(lora)
data = filtered
return data return data
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]: def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
@@ -432,7 +367,9 @@ class LoraService(BaseModelService):
rng.uniform(clip_strength_min, clip_strength_max), 2 rng.uniform(clip_strength_min, clip_strength_max), 2
) )
else: else:
clip_str = round(rng.uniform(clip_strength_min, clip_strength_max), 2) clip_str = round(
rng.uniform(clip_strength_min, clip_strength_max), 2
)
result_loras.append( result_loras.append(
{ {
@@ -547,69 +484,12 @@ class LoraService(BaseModelService):
if bool(lora.get("license_flags", 127) & (1 << 1)) if bool(lora.get("license_flags", 127) & (1 << 1))
] ]
# Apply name pattern filters
name_patterns = filter_section.get("namePatterns", {})
include_patterns = name_patterns.get("include", [])
exclude_patterns = name_patterns.get("exclude", [])
use_regex = name_patterns.get("useRegex", False)
if include_patterns or exclude_patterns:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in available_loras:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if exclude_patterns:
for name in names_to_check:
if matches_any_pattern(name, exclude_patterns, use_regex):
excluded = True
break
if excluded:
continue
# Check include patterns
if include_patterns:
included = False
for name in names_to_check:
if matches_any_pattern(name, include_patterns, use_regex):
included = True
break
if not included:
continue
filtered.append(lora)
available_loras = filtered
return available_loras return available_loras
async def get_cycler_list( async def get_cycler_list(
self, pool_config: Optional[Dict] = None, sort_by: str = "filename" self,
pool_config: Optional[Dict] = None,
sort_by: str = "filename"
) -> List[Dict]: ) -> List[Dict]:
""" """
Get filtered and sorted LoRA list for cycling. Get filtered and sorted LoRA list for cycling.
@@ -635,18 +515,12 @@ class LoraService(BaseModelService):
if sort_by == "model_name": if sort_by == "model_name":
available_loras = sorted( available_loras = sorted(
available_loras, available_loras,
key=lambda x: ( key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
(x.get("model_name") or x.get("file_name", "")).lower(),
x.get("file_path", "").lower(),
),
) )
else: # Default to filename else: # Default to filename
available_loras = sorted( available_loras = sorted(
available_loras, available_loras,
key=lambda x: ( key=lambda x: x.get("file_name", "").lower()
x.get("file_name", "").lower(),
x.get("file_path", "").lower(),
),
) )
# Return minimal data needed for cycling # Return minimal data needed for cycling

View File

@@ -44,8 +44,6 @@ async def initialize_metadata_providers():
logger.debug(f"SQLite metadata provider registered with database: {db_path}") logger.debug(f"SQLite metadata provider registered with database: {db_path}")
else: else:
logger.warning("Metadata archive database is enabled but database file not found") logger.warning("Metadata archive database is enabled but database file not found")
logger.info("Automatically disabling enable_metadata_archive_db setting")
settings_manager.set('enable_metadata_archive_db', False)
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize SQLite metadata provider: {e}") logger.error(f"Failed to initialize SQLite metadata provider: {e}")

View File

@@ -243,27 +243,17 @@ class MetadataSyncService:
last_error = error or last_error last_error = error or last_error
if civitai_metadata is None or metadata_provider is None: if civitai_metadata is None or metadata_provider is None:
# Track if we need to save metadata
needs_save = False
if sqlite_attempted: if sqlite_attempted:
model_data["db_checked"] = True model_data["db_checked"] = True
needs_save = True
if civitai_api_not_found: if civitai_api_not_found:
model_data["from_civitai"] = False model_data["from_civitai"] = False
model_data["civitai_deleted"] = True model_data["civitai_deleted"] = True
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False)) model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
model_data["last_checked_at"] = datetime.now().timestamp() model_data["last_checked_at"] = datetime.now().timestamp()
needs_save = True
# Save metadata if any state was updated
if needs_save:
data_to_save = model_data.copy() data_to_save = model_data.copy()
data_to_save.pop("folder", None) data_to_save.pop("folder", None)
# Update last_checked_at for sqlite-only attempts if not already set
if "last_checked_at" not in data_to_save:
data_to_save["last_checked_at"] = datetime.now().timestamp()
await self._metadata_manager.save_metadata(file_path, data_to_save) await self._metadata_manager.save_metadata(file_path, data_to_save)
default_error = ( default_error = (

View File

@@ -0,0 +1,55 @@
import logging
from typing import Any, Dict, List, Optional
from ..utils.models import MiscMetadata
from ..config import config
from .model_scanner import ModelScanner
from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__)
class MiscScanner(ModelScanner):
"""Service for scanning and managing misc files (VAE, Upscaler)"""
def __init__(self):
# Define supported file extensions (combined from VAE and upscaler)
file_extensions = {'.safetensors', '.pt', '.bin', '.ckpt', '.pth'}
super().__init__(
model_type="misc",
model_class=MiscMetadata,
file_extensions=file_extensions,
hash_index=ModelHashIndex()
)
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
"""Resolve the sub-type based on the root path."""
if not root_path:
return None
if config.vae_roots and root_path in config.vae_roots:
return "vae"
if config.upscaler_roots and root_path in config.upscaler_roots:
return "upscaler"
return None
def adjust_metadata(self, metadata, file_path, root_path):
"""Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path)
if sub_type:
metadata.sub_type = sub_type
return metadata
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
sub_type = self._resolve_sub_type(
self._find_root_for_file(entry.get("file_path"))
)
if sub_type:
entry["sub_type"] = sub_type
return entry
def get_model_roots(self) -> List[str]:
"""Get misc root directories (VAE and upscaler)"""
return config.misc_roots

View File

@@ -0,0 +1,55 @@
import os
import logging
from typing import Dict
from .base_model_service import BaseModelService
from ..utils.models import MiscMetadata
from ..config import config
logger = logging.getLogger(__name__)
class MiscService(BaseModelService):
"""Misc-specific service implementation (VAE, Upscaler)"""
def __init__(self, scanner, update_service=None):
"""Initialize Misc service
Args:
scanner: Misc scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("misc", scanner, MiscMetadata, update_service=update_service)
async def format_response(self, misc_data: Dict) -> Dict:
"""Format Misc data for API response"""
# Get sub_type from cache entry (new canonical field)
sub_type = misc_data.get("sub_type", "vae")
return {
"model_name": misc_data["model_name"],
"file_name": misc_data["file_name"],
"preview_url": config.get_preview_static_url(misc_data.get("preview_url", "")),
"preview_nsfw_level": misc_data.get("preview_nsfw_level", 0),
"base_model": misc_data.get("base_model", ""),
"folder": misc_data["folder"],
"sha256": misc_data.get("sha256", ""),
"file_path": misc_data["file_path"].replace(os.sep, "/"),
"file_size": misc_data.get("size", 0),
"modified": misc_data.get("modified", ""),
"tags": misc_data.get("tags", []),
"from_civitai": misc_data.get("from_civitai", True),
"usage_count": misc_data.get("usage_count", 0),
"notes": misc_data.get("notes", ""),
"sub_type": sub_type,
"favorite": misc_data.get("favorite", False),
"update_available": bool(misc_data.get("update_available", False)),
"civitai": self.filter_civitai_data(misc_data.get("civitai", {}), minimal=True)
}
def find_duplicate_hashes(self) -> Dict:
"""Find Misc models with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict:
"""Find Misc models with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames()

View File

@@ -5,6 +5,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted from natsort import natsorted
# Supported sort modes: (sort_key, order) # Supported sort modes: (sort_key, order)
@@ -221,45 +222,33 @@ class ModelCache:
start_time = time.perf_counter() start_time = time.perf_counter()
reverse = (order == 'desc') reverse = (order == 'desc')
if sort_key == 'name': if sort_key == 'name':
# Natural sort by configured display name, case-insensitive, with file_path as tie-breaker # Natural sort by configured display name, case-insensitive
result = natsorted( result = natsorted(
data, data,
key=lambda x: ( key=lambda x: self._get_display_name(x).lower(),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse reverse=reverse
) )
elif sort_key == 'date': elif sort_key == 'date':
# Sort by modified timestamp, fallback to name and path for stability # Sort by modified timestamp
result = sorted( result = sorted(
data, data,
key=lambda x: ( key=itemgetter('modified'),
x.get('modified', 0.0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse reverse=reverse
) )
elif sort_key == 'size': elif sort_key == 'size':
# Sort by file size, fallback to name and path for stability # Sort by file size
result = sorted( result = sorted(
data, data,
key=lambda x: ( key=itemgetter('size'),
x.get('size', 0),
self._get_display_name(x).lower(),
x.get('file_path', '').lower()
),
reverse=reverse reverse=reverse
) )
elif sort_key == 'usage': elif sort_key == 'usage':
# Sort by usage count, fallback to 0, then name and path for stability # Sort by usage count, fallback to 0, then name for stability
return sorted( return sorted(
data, data,
key=lambda x: ( key=lambda x: (
x.get('usage_count', 0), x.get('usage_count', 0),
self._get_display_name(x).lower(), self._get_display_name(x).lower()
x.get('file_path', '').lower()
), ),
reverse=reverse reverse=reverse
) )

View File

@@ -676,9 +676,7 @@ class ModelMetadataProviderManager:
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider: def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
"""Get provider by name or default provider""" """Get provider by name or default provider"""
if provider_name: if provider_name and provider_name in self.providers:
if provider_name not in self.providers:
raise ValueError(f"Provider '{provider_name}' is not registered")
return self.providers[provider_name] return self.providers[provider_name]
if self.default_provider is None: if self.default_provider is None:

View File

@@ -99,7 +99,6 @@ class FilterCriteria:
favorites_only: bool = False favorites_only: bool = False
search_options: Optional[Dict[str, Any]] = None search_options: Optional[Dict[str, Any]] = None
model_types: Optional[Sequence[str]] = None model_types: Optional[Sequence[str]] = None
tag_logic: str = "any" # "any" (OR) or "all" (AND)
class ModelCacheRepository: class ModelCacheRepository:
@@ -301,28 +300,10 @@ class ModelFilterSet:
include_tags = {tag for tag in tag_filters if tag} include_tags = {tag for tag in tag_filters if tag}
if include_tags: if include_tags:
tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any"
def matches_include(item_tags): def matches_include(item_tags):
if not item_tags and "__no_tags__" in include_tags: if not item_tags and "__no_tags__" in include_tags:
return True return True
if tag_logic == "all":
# AND logic: item must have ALL include tags
# Special case: __no_tags__ is handled separately
non_special_tags = include_tags - {"__no_tags__"}
if "__no_tags__" in include_tags:
# If __no_tags__ is selected along with other tags,
# treat it as "no tags OR (all other tags)"
if not item_tags:
return True
# Otherwise, check if all non-special tags match
if non_special_tags:
return all(tag in (item_tags or []) for tag in non_special_tags)
return True
# Normal case: all tags must match
return all(tag in (item_tags or []) for tag in non_special_tags)
else:
# OR logic (default): item must have ANY include tag
return any(tag in include_tags for tag in (item_tags or [])) return any(tag in include_tags for tag in (item_tags or []))
items = [item for item in items if matches_include(item.get("tags"))] items = [item for item in items if matches_include(item.get("tags"))]

View File

@@ -14,13 +14,12 @@ from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex from .model_hash_index import ModelHashIndex
from ..utils.constants import PREVIEW_EXTENSIONS
from .model_lifecycle_service import delete_model_artifacts from .model_lifecycle_service import delete_model_artifacts
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager from .websocket_manager import ws_manager
from .persistent_model_cache import get_persistent_cache from .persistent_model_cache import get_persistent_cache
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .cache_entry_validator import CacheEntryValidator
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -247,7 +246,6 @@ class ModelScanner:
'tags': tags_list, 'tags': tags_list,
'civitai': civitai_slim, 'civitai': civitai_slim,
'civitai_deleted': bool(get_value('civitai_deleted', False)), 'civitai_deleted': bool(get_value('civitai_deleted', False)),
'skip_metadata_refresh': bool(get_value('skip_metadata_refresh', False)),
} }
license_source: Dict[str, Any] = {} license_source: Dict[str, Any] = {}
@@ -282,11 +280,6 @@ class ModelScanner:
if sub_type: if sub_type:
entry['sub_type'] = sub_type entry['sub_type'] = sub_type
# Handle hash_status for lazy hash calculation (checkpoints)
hash_status = get_value('hash_status', 'completed')
if hash_status:
entry['hash_status'] = hash_status
return entry return entry
def _ensure_license_flags(self, entry: Dict[str, Any]) -> None: def _ensure_license_flags(self, entry: Dict[str, Any]) -> None:
@@ -475,39 +468,6 @@ class ModelScanner:
for tag in adjusted_item.get('tags') or []: for tag in adjusted_item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1 tags_count[tag] = tags_count.get(tag, 0) + 1
# Validate cache entries and check health
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
adjusted_raw_data, auto_repair=True
)
if invalid_entries:
monitor = CacheHealthMonitor()
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
if report.status != CacheHealthStatus.HEALTHY:
# Broadcast health warning to frontend
await ws_manager.broadcast_cache_health_warning(report, page_type)
logger.warning(
f"{self.model_type.capitalize()} Scanner: Cache health issue detected - "
f"{report.invalid_entries} invalid entries, {report.repaired_entries} repaired"
)
# Use only valid entries
adjusted_raw_data = valid_entries
# Rebuild tags count from valid entries only
tags_count = {}
for item in adjusted_raw_data:
for tag in item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
# Remove invalid entries from hash index
for invalid_entry in invalid_entries:
file_path = CacheEntryValidator.get_file_path_safe(invalid_entry)
sha256 = CacheEntryValidator.get_sha256_safe(invalid_entry)
if file_path:
hash_index.remove_by_path(file_path, sha256)
scan_result = CacheBuildResult( scan_result = CacheBuildResult(
raw_data=adjusted_raw_data, raw_data=adjusted_raw_data,
hash_index=hash_index, hash_index=hash_index,
@@ -691,6 +651,7 @@ class ModelScanner:
async def _initialize_cache(self) -> None: async def _initialize_cache(self) -> None:
"""Initialize or refresh the cache""" """Initialize or refresh the cache"""
print("init start", flush=True)
self._is_initializing = True # Set flag self._is_initializing = True # Set flag
try: try:
start_time = time.time() start_time = time.time()
@@ -704,6 +665,7 @@ class ModelScanner:
scan_result = await self._gather_model_data() scan_result = await self._gather_model_data()
await self._apply_scan_result(scan_result) await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result) await self._save_persistent_cache(scan_result)
print("init end", flush=True)
logger.info( logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, " f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
@@ -814,18 +776,6 @@ class ModelScanner:
model_data = self.adjust_cached_entry(dict(model_data)) model_data = self.adjust_cached_entry(dict(model_data))
if not model_data: if not model_data:
continue continue
# Validate the new entry before adding
validation_result = CacheEntryValidator.validate(
model_data, auto_repair=True
)
if not validation_result.is_valid:
logger.warning(
f"Skipping invalid entry during reconcile: {path}"
)
continue
model_data = validation_result.entry
self._ensure_license_flags(model_data) self._ensure_license_flags(model_data)
# Add to cache # Add to cache
self._cache.raw_data.append(model_data) self._cache.raw_data.append(model_data)
@@ -1140,17 +1090,6 @@ class ModelScanner:
processed_files += 1 processed_files += 1
if result: if result:
# Validate the entry before adding
validation_result = CacheEntryValidator.validate(
result, auto_repair=True
)
if not validation_result.is_valid:
logger.warning(
f"Skipping invalid scan result: {file_path}"
)
continue
result = validation_result.entry
self._ensure_license_flags(result) self._ensure_license_flags(result)
raw_data.append(result) raw_data.append(result)
@@ -1442,16 +1381,17 @@ class ModelScanner:
if not file_path: if not file_path:
return None return None
dir_path = os.path.dirname(file_path) base_name = os.path.splitext(file_path)[0]
base_name = os.path.splitext(os.path.basename(file_path))[0]
preview_path = find_preview_file(base_name, dir_path) for ext in PREVIEW_EXTENSIONS:
if preview_path: preview_path = f"{base_name}{ext}"
if os.path.exists(preview_path):
return config.get_preview_static_url(preview_path) return config.get_preview_static_url(preview_path)
return None return None
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]: async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get top tags sorted by count. If limit is 0, return all tags.""" """Get top tags sorted by count"""
await self.get_cached_data() await self.get_cached_data()
sorted_tags = sorted( sorted_tags = sorted(
@@ -1460,8 +1400,6 @@ class ModelScanner:
reverse=True reverse=True
) )
if limit == 0:
return sorted_tags
return sorted_tags[:limit] return sorted_tags[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:

View File

@@ -118,13 +118,15 @@ class ModelServiceFactory:
def register_default_model_types(): def register_default_model_types():
"""Register the default model types (LoRA, Checkpoint, and Embedding)""" """Register the default model types (LoRA, Checkpoint, Embedding, and Misc)"""
from ..services.lora_service import LoraService from ..services.lora_service import LoraService
from ..services.checkpoint_service import CheckpointService from ..services.checkpoint_service import CheckpointService
from ..services.embedding_service import EmbeddingService from ..services.embedding_service import EmbeddingService
from ..services.misc_service import MiscService
from ..routes.lora_routes import LoraRoutes from ..routes.lora_routes import LoraRoutes
from ..routes.checkpoint_routes import CheckpointRoutes from ..routes.checkpoint_routes import CheckpointRoutes
from ..routes.embedding_routes import EmbeddingRoutes from ..routes.embedding_routes import EmbeddingRoutes
from ..routes.misc_model_routes import MiscModelRoutes
# Register LoRA model type # Register LoRA model type
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes) ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
@@ -134,3 +136,6 @@ def register_default_model_types():
# Register Embedding model type # Register Embedding model type
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes) ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
# Register Misc model type (VAE, Upscaler)
ModelServiceFactory.register_model_type('misc', MiscService, MiscModelRoutes)

View File

@@ -7,8 +7,7 @@ import os
import sqlite3 import sqlite3
import time import time
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import datetime, timezone from typing import Dict, Iterable, List, Mapping, Optional, Sequence
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
@@ -65,9 +64,7 @@ class ModelVersionRecord:
preview_url: Optional[str] preview_url: Optional[str]
is_in_library: bool is_in_library: bool
should_ignore: bool should_ignore: bool
early_access_ends_at: Optional[str] = None
sort_index: int = 0 sort_index: int = 0
is_early_access: bool = False
@dataclass @dataclass
@@ -100,12 +97,8 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library] return [version.version_id for version in self.versions if version.is_in_library]
def has_update(self, hide_early_access: bool = False) -> bool: def has_update(self) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available. """Return True when a non-ignored remote version newer than the newest local copy is available."""
Args:
hide_early_access: If True, exclude early access versions from update check.
"""
if self.should_ignore_model: if self.should_ignore_model:
return False return False
@@ -117,56 +110,22 @@ class ModelUpdateRecord:
if max_in_library is None: if max_in_library is None:
return any( return any(
not version.is_in_library not version.is_in_library and not version.should_ignore for version in self.versions
and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
for version in self.versions
) )
for version in self.versions: for version in self.versions:
if version.is_in_library or version.should_ignore: if version.is_in_library or version.should_ignore:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if version.version_id > max_in_library: if version.version_id > max_in_library:
return True return True
return False return False
@staticmethod
def _is_early_access_active(version: ModelVersionRecord) -> bool:
"""Check if a version is currently in early access period.
Uses two-phase detection:
1. If exact EA end time available (from single version API), use it for precise check
2. Otherwise fallback to basic EA flag (from bulk API)
"""
# Phase 2: Precise check with exact end time
if version.early_access_ends_at:
try:
ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00")
)
return ea_date > datetime.now(timezone.utc)
except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative)
return True
# Phase 1: Basic EA flag from bulk API
return version.is_early_access
def has_update_for_base( def has_update_for_base(
self, self,
local_version_id: Optional[int], local_version_id: Optional[int],
local_base_model: Optional[str], local_base_model: Optional[str],
hide_early_access: bool = False,
) -> bool: ) -> bool:
"""Return True when a newer remote version with the same base model exists. """Return True when a newer remote version with the same base model exists."""
Args:
local_version_id: The current local version id.
local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check.
"""
if self.should_ignore_model: if self.should_ignore_model:
return False return False
@@ -194,8 +153,6 @@ class ModelUpdateRecord:
for version in self.versions: for version in self.versions:
if version.is_in_library or version.should_ignore: if version.is_in_library or version.should_ignore:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
version_base = _normalize_base_model(version.base_model) version_base = _normalize_base_model(version.base_model)
if version_base != normalized_base: if version_base != normalized_base:
continue continue
@@ -311,14 +268,6 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN should_ignore INTEGER NOT NULL DEFAULT 0" "ADD COLUMN should_ignore INTEGER NOT NULL DEFAULT 0"
), ),
"early_access_ends_at": (
"ALTER TABLE model_update_versions "
"ADD COLUMN early_access_ends_at TEXT"
),
"is_early_access": (
"ALTER TABLE model_update_versions "
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
),
} }
for column, statement in migrations.items(): for column, statement in migrations.items():
@@ -418,8 +367,6 @@ class ModelUpdateService:
preview_url TEXT, preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0, is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0,
early_access_ends_at TEXT,
is_early_access INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
) )
@@ -437,8 +384,6 @@ class ModelUpdateService:
"preview_url", "preview_url",
"is_in_library", "is_in_library",
"should_ignore", "should_ignore",
"early_access_ends_at",
"is_early_access",
] ]
defaults = { defaults = {
"sort_index": "0", "sort_index": "0",
@@ -449,8 +394,6 @@ class ModelUpdateService:
"preview_url": "NULL", "preview_url": "NULL",
"is_in_library": "0", "is_in_library": "0",
"should_ignore": "0", "should_ignore": "0",
"early_access_ends_at": "NULL",
"is_early_access": "0",
} }
select_parts = [] select_parts = []
@@ -724,8 +667,6 @@ class ModelUpdateService:
is_in_library=False, is_in_library=False,
should_ignore=should_ignore, should_ignore=should_ignore,
sort_index=len(versions), sort_index=len(versions),
early_access_ends_at=None,
is_early_access=False,
) )
) )
@@ -745,17 +686,16 @@ class ModelUpdateService:
async with self._lock: async with self._lock:
return self._get_record(model_type, model_id) return self._get_record(model_type, model_id)
async def has_update(self, model_type: str, model_id: int, hide_early_access: bool = False) -> bool: async def has_update(self, model_type: str, model_id: int) -> bool:
"""Determine if a model has updates pending.""" """Determine if a model has updates pending."""
record = await self.get_record(model_type, model_id) record = await self.get_record(model_type, model_id)
return record.has_update(hide_early_access=hide_early_access) if record else False return record.has_update() if record else False
async def has_updates_bulk( async def has_updates_bulk(
self, self,
model_type: str, model_type: str,
model_ids: Sequence[int], model_ids: Sequence[int],
hide_early_access: bool = False,
) -> Dict[int, bool]: ) -> Dict[int, bool]:
"""Return update availability for each model id in a single database pass.""" """Return update availability for each model id in a single database pass."""
@@ -767,7 +707,7 @@ class ModelUpdateService:
records = self._get_records_bulk(model_type, normalized_ids) records = self._get_records_bulk(model_type, normalized_ids)
return { return {
model_id: records.get(model_id).has_update(hide_early_access=hide_early_access) if records.get(model_id) else False model_id: records.get(model_id).has_update() if records.get(model_id) else False
for model_id in normalized_ids for model_id in normalized_ids
} }
@@ -1047,8 +987,6 @@ class ModelUpdateService:
is_in_library=True, is_in_library=True,
should_ignore=ignore_map.get(missing_id, False), should_ignore=ignore_map.get(missing_id, False),
sort_index=len(versions), sort_index=len(versions),
early_access_ends_at=None,
is_early_access=False,
) )
) )
@@ -1091,8 +1029,6 @@ class ModelUpdateService:
is_in_library=version_id in local_set, is_in_library=version_id in local_set,
should_ignore=ignore_map.get(version_id, remote_version.should_ignore), should_ignore=ignore_map.get(version_id, remote_version.should_ignore),
sort_index=sort_map.get(version_id, index), sort_index=sort_map.get(version_id, index),
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
) )
) )
@@ -1119,8 +1055,6 @@ class ModelUpdateService:
is_in_library=True, is_in_library=True,
should_ignore=ignore_map.get(version_id, False), should_ignore=ignore_map.get(version_id, False),
sort_index=len(versions), sort_index=len(versions),
early_access_ends_at=None,
is_early_access=False,
) )
) )
@@ -1186,11 +1120,6 @@ class ModelUpdateService:
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt")) released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
size_bytes = self._extract_size_bytes(entry.get("files")) size_bytes = self._extract_size_bytes(entry.get("files"))
preview_url = self._extract_preview_url(entry.get("images")) preview_url = self._extract_preview_url(entry.get("images"))
early_access_ends_at = _normalize_string(entry.get("earlyAccessEndsAt"))
# Check availability field from bulk API for basic EA detection
availability = _normalize_string(entry.get("availability"))
is_early_access = availability == "EarlyAccess"
return ModelVersionRecord( return ModelVersionRecord(
version_id=version_id, version_id=version_id,
@@ -1201,9 +1130,7 @@ class ModelUpdateService:
preview_url=preview_url, preview_url=preview_url,
is_in_library=False, is_in_library=False,
should_ignore=False, should_ignore=False,
early_access_ends_at=early_access_ends_at,
sort_index=index, sort_index=index,
is_early_access=is_early_access,
) )
def _extract_size_bytes(self, files) -> Optional[int]: def _extract_size_bytes(self, files) -> Optional[int]:
@@ -1304,8 +1231,7 @@ class ModelUpdateService:
version_rows = conn.execute( version_rows = conn.execute(
f""" f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at, SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, size_bytes, preview_url, is_in_library, should_ignore
is_early_access
FROM model_update_versions FROM model_update_versions
WHERE model_id IN ({placeholders}) WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1326,9 +1252,7 @@ class ModelUpdateService:
preview_url=row["preview_url"], preview_url=row["preview_url"],
is_in_library=bool(row["is_in_library"]), is_in_library=bool(row["is_in_library"]),
should_ignore=bool(row["should_ignore"]), should_ignore=bool(row["should_ignore"]),
early_access_ends_at=row["early_access_ends_at"],
sort_index=_normalize_int(row["sort_index"]) or 0, sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]),
) )
) )
@@ -1384,9 +1308,8 @@ class ModelUpdateService:
""" """
INSERT INTO model_update_versions ( INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at, version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, size_bytes, preview_url, is_in_library, should_ignore
is_early_access ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -1399,8 +1322,6 @@ class ModelUpdateService:
version.preview_url, version.preview_url,
1 if version.is_in_library else 0, 1 if version.is_in_library else 0,
1 if version.should_ignore else 0, 1 if version.should_ignore else 0,
version.early_access_ends_at,
1 if version.is_early_access else 0,
), ),
) )
conn.commit() conn.commit()

View File

@@ -52,11 +52,9 @@ class PersistentModelCache:
"trained_words", "trained_words",
"license_flags", "license_flags",
"civitai_deleted", "civitai_deleted",
"skip_metadata_refresh",
"exclude", "exclude",
"db_checked", "db_checked",
"last_checked_at", "last_checked_at",
"hash_status",
) )
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:] _MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {} _instances: Dict[str, "PersistentModelCache"] = {}
@@ -185,9 +183,7 @@ class PersistentModelCache:
"tags": tags.get(file_path, []), "tags": tags.get(file_path, []),
"civitai": civitai, "civitai": civitai,
"civitai_deleted": bool(row["civitai_deleted"]), "civitai_deleted": bool(row["civitai_deleted"]),
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
"license_flags": int(license_value), "license_flags": int(license_value),
"hash_status": row["hash_status"] or "completed",
} }
raw_data.append(item) raw_data.append(item)
@@ -451,7 +447,6 @@ class PersistentModelCache:
exclude INTEGER, exclude INTEGER,
db_checked INTEGER, db_checked INTEGER,
last_checked_at REAL, last_checked_at REAL,
hash_status TEXT,
PRIMARY KEY (model_type, file_path) PRIMARY KEY (model_type, file_path)
); );
@@ -496,10 +491,8 @@ class PersistentModelCache:
"civitai_creator_username": "TEXT", "civitai_creator_username": "TEXT",
"civitai_model_type": "TEXT", "civitai_model_type": "TEXT",
"civitai_deleted": "INTEGER DEFAULT 0", "civitai_deleted": "INTEGER DEFAULT 0",
"skip_metadata_refresh": "INTEGER DEFAULT 0",
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57). # Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}", "license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
"hash_status": "TEXT DEFAULT 'completed'",
} }
for column, definition in required_columns.items(): for column, definition in required_columns.items():
@@ -570,11 +563,9 @@ class PersistentModelCache:
trained_words_json, trained_words_json,
int(license_flags), int(license_flags),
1 if item.get("civitai_deleted") else 0, 1 if item.get("civitai_deleted") else 0,
1 if item.get("skip_metadata_refresh") else 0,
1 if item.get("exclude") else 0, 1 if item.get("exclude") else 0,
1 if item.get("db_checked") else 0, 1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0), float(item.get("last_checked_at") or 0.0),
item.get("hash_status", "completed"),
) )
def _insert_model_sql(self) -> str: def _insert_model_sql(self) -> str:

View File

@@ -4,7 +4,6 @@ from dataclasses import dataclass
from operator import itemgetter from operator import itemgetter
from natsort import natsorted from natsort import natsorted
@dataclass @dataclass
class RecipeCache: class RecipeCache:
"""Cache structure for Recipe data""" """Cache structure for Recipe data"""
@@ -22,18 +21,11 @@ class RecipeCache:
self.folder_tree = self.folder_tree or {} self.folder_tree = self.folder_tree or {}
async def resort(self, name_only: bool = False): async def resort(self, name_only: bool = False):
"""Resort all cached data views in a thread pool to avoid blocking the event loop.""" """Resort all cached data views"""
async with self._lock: async with self._lock:
loop = asyncio.get_event_loop() self._resort_locked(name_only=name_only)
await loop.run_in_executor(
None,
self._resort_locked,
name_only,
)
async def update_recipe_metadata( async def update_recipe_metadata(self, recipe_id: str, metadata: Dict, *, resort: bool = True) -> bool:
self, recipe_id: str, metadata: Dict, *, resort: bool = True
) -> bool:
"""Update metadata for a specific recipe in all cached data """Update metadata for a specific recipe in all cached data
Args: Args:
@@ -45,7 +37,7 @@ class RecipeCache:
""" """
async with self._lock: async with self._lock:
for item in self.raw_data: for item in self.raw_data:
if str(item.get("id")) == str(recipe_id): if str(item.get('id')) == str(recipe_id):
item.update(metadata) item.update(metadata)
if resort: if resort:
self._resort_locked() self._resort_locked()
@@ -60,9 +52,7 @@ class RecipeCache:
if resort: if resort:
self._resort_locked() self._resort_locked()
async def remove_recipe( async def remove_recipe(self, recipe_id: str, *, resort: bool = False) -> Optional[Dict]:
self, recipe_id: str, *, resort: bool = False
) -> Optional[Dict]:
"""Remove a recipe from the cache by ID. """Remove a recipe from the cache by ID.
Args: Args:
@@ -74,16 +64,14 @@ class RecipeCache:
async with self._lock: async with self._lock:
for index, recipe in enumerate(self.raw_data): for index, recipe in enumerate(self.raw_data):
if str(recipe.get("id")) == str(recipe_id): if str(recipe.get('id')) == str(recipe_id):
removed = self.raw_data.pop(index) removed = self.raw_data.pop(index)
if resort: if resort:
self._resort_locked() self._resort_locked()
return removed return removed
return None return None
async def bulk_remove( async def bulk_remove(self, recipe_ids: Iterable[str], *, resort: bool = False) -> List[Dict]:
self, recipe_ids: Iterable[str], *, resort: bool = False
) -> List[Dict]:
"""Remove multiple recipes from the cache.""" """Remove multiple recipes from the cache."""
id_set = {str(recipe_id) for recipe_id in recipe_ids} id_set = {str(recipe_id) for recipe_id in recipe_ids}
@@ -91,25 +79,21 @@ class RecipeCache:
return [] return []
async with self._lock: async with self._lock:
removed = [item for item in self.raw_data if str(item.get("id")) in id_set] removed = [item for item in self.raw_data if str(item.get('id')) in id_set]
if not removed: if not removed:
return [] return []
self.raw_data = [ self.raw_data = [item for item in self.raw_data if str(item.get('id')) not in id_set]
item for item in self.raw_data if str(item.get("id")) not in id_set
]
if resort: if resort:
self._resort_locked() self._resort_locked()
return removed return removed
async def replace_recipe( async def replace_recipe(self, recipe_id: str, new_data: Dict, *, resort: bool = False) -> bool:
self, recipe_id: str, new_data: Dict, *, resort: bool = False
) -> bool:
"""Replace cached data for a recipe.""" """Replace cached data for a recipe."""
async with self._lock: async with self._lock:
for index, recipe in enumerate(self.raw_data): for index, recipe in enumerate(self.raw_data):
if str(recipe.get("id")) == str(recipe_id): if str(recipe.get('id')) == str(recipe_id):
self.raw_data[index] = new_data self.raw_data[index] = new_data
if resort: if resort:
self._resort_locked() self._resort_locked()
@@ -121,7 +105,7 @@ class RecipeCache:
async with self._lock: async with self._lock:
for recipe in self.raw_data: for recipe in self.raw_data:
if str(recipe.get("id")) == str(recipe_id): if str(recipe.get('id')) == str(recipe_id):
return dict(recipe) return dict(recipe)
return None return None
@@ -131,14 +115,16 @@ class RecipeCache:
async with self._lock: async with self._lock:
return [dict(item) for item in self.raw_data] return [dict(item) for item in self.raw_data]
def _resort_locked(self, name_only: bool = False) -> None: def _resort_locked(self, *, name_only: bool = False) -> None:
"""Sort cached views. Caller must hold ``_lock``.""" """Sort cached views. Caller must hold ``_lock``."""
self.sorted_by_name = natsorted( self.sorted_by_name = natsorted(
self.raw_data, self.raw_data,
key=lambda x: (x.get("title", "").lower(), x.get("file_path", "").lower()), key=lambda x: x.get('title', '').lower()
) )
if not name_only: if not name_only:
self.sorted_by_date = sorted( self.sorted_by_date = sorted(
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True self.raw_data,
key=itemgetter('created_date', 'file_path'),
reverse=True
) )

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
"""Services responsible for recipe metadata analysis.""" """Services responsible for recipe metadata analysis."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
@@ -70,9 +69,7 @@ class RecipeAnalysisService:
try: try:
metadata = self._exif_utils.extract_image_metadata(temp_path) metadata = self._exif_utils.extract_image_metadata(temp_path)
if not metadata: if not metadata:
return AnalysisResult( return AnalysisResult({"error": "No metadata found in this image", "loras": []})
{"error": "No metadata found in this image", "loras": []}
)
return await self._parse_metadata( return await self._parse_metadata(
metadata, metadata,
@@ -108,9 +105,7 @@ class RecipeAnalysisService:
if civitai_match: if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError( raise RecipeDownloadError("Failed to fetch image information from Civitai")
"Failed to fetch image information from Civitai"
)
image_url = image_info.get("url") image_url = image_info.get("url")
if not image_url: if not image_url:
@@ -119,15 +114,13 @@ class RecipeAnalysisService:
is_video = image_info.get("type") == "video" is_video = image_info.get("type") == "video"
# Use optimized preview URLs if possible # Use optimized preview URLs if possible
rewritten_url, _ = rewrite_preview_url( rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
image_url, media_type=image_info.get("type")
)
if rewritten_url: if rewritten_url:
image_url = rewritten_url image_url = rewritten_url
if is_video: if is_video:
# Extract extension from URL # Extract extension from URL
url_path = image_url.split("?")[0].split("#")[0] url_path = image_url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower() or ".mp4" extension = os.path.splitext(url_path)[1].lower() or ".mp4"
else: else:
extension = ".jpg" extension = ".jpg"
@@ -142,17 +135,9 @@ class RecipeAnalysisService:
and isinstance(metadata["meta"], dict) and isinstance(metadata["meta"], dict)
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
# Validate that metadata contains meaningful recipe fields
# If not, treat as None to trigger EXIF extraction from downloaded image
if isinstance(metadata, dict) and not self._has_recipe_fields(metadata):
self._logger.debug(
"Civitai API metadata lacks recipe fields, will extract from EXIF"
)
metadata = None
else: else:
# Basic extension detection for non-Civitai URLs # Basic extension detection for non-Civitai URLs
url_path = url.split("?")[0].split("#")[0] url_path = url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower() extension = os.path.splitext(url_path)[1].lower()
if extension in [".mp4", ".webm"]: if extension in [".mp4", ".webm"]:
is_video = True is_video = True
@@ -226,9 +211,7 @@ class RecipeAnalysisService:
image_bytes = self._convert_tensor_to_png_bytes(latest_image) image_bytes = self._convert_tensor_to_png_bytes(latest_image)
if image_bytes is None: if image_bytes is None:
raise RecipeValidationError( raise RecipeValidationError("Cannot handle this data shape from metadata registry")
"Cannot handle this data shape from metadata registry"
)
return AnalysisResult( return AnalysisResult(
{ {
@@ -239,22 +222,6 @@ class RecipeAnalysisService:
# Internal helpers ------------------------------------------------- # Internal helpers -------------------------------------------------
def _has_recipe_fields(self, metadata: dict[str, Any]) -> bool:
"""Check if metadata contains meaningful recipe-related fields."""
recipe_fields = {
"prompt",
"negative_prompt",
"resources",
"hashes",
"params",
"generationData",
"Workflow",
"prompt_type",
"positive",
"negative",
}
return any(field in metadata for field in recipe_fields)
async def _parse_metadata( async def _parse_metadata(
self, self,
metadata: dict[str, Any], metadata: dict[str, Any],
@@ -267,12 +234,7 @@ class RecipeAnalysisService:
) -> AnalysisResult: ) -> AnalysisResult:
parser = self._recipe_parser_factory.create_parser(metadata) parser = self._recipe_parser_factory.create_parser(metadata)
if parser is None: if parser is None:
# Provide more specific error message based on metadata source payload = {"error": "No parser found for this image", "loras": []}
if not metadata:
error_msg = "This image does not contain any generation metadata (prompt, models, or parameters)"
else:
error_msg = "No parser found for this image"
payload = {"error": error_msg, "loras": []}
if include_image_base64 and image_path: if include_image_base64 and image_path:
payload["image_base64"] = self._encode_file(image_path) payload["image_base64"] = self._encode_file(image_path)
payload["is_video"] = is_video payload["is_video"] = is_video
@@ -295,9 +257,7 @@ class RecipeAnalysisService:
matching_recipes: list[str] = [] matching_recipes: list[str] = []
if fingerprint: if fingerprint:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint( matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(fingerprint)
fingerprint
)
result["matching_recipes"] = matching_recipes result["matching_recipes"] = matching_recipes
return AnalysisResult(result) return AnalysisResult(result)
@@ -309,10 +269,7 @@ class RecipeAnalysisService:
raise RecipeDownloadError(f"Failed to download image from URL: {result}") raise RecipeDownloadError(f"Failed to download image from URL: {result}")
def _metadata_not_found_response(self, path: str) -> AnalysisResult: def _metadata_not_found_response(self, path: str) -> AnalysisResult:
payload: dict[str, Any] = { payload: dict[str, Any] = {"error": "No metadata found in this image", "loras": []}
"error": "No metadata found in this image",
"loras": [],
}
if os.path.exists(path): if os.path.exists(path):
payload["image_base64"] = self._encode_file(path) payload["image_base64"] = self._encode_file(path)
return AnalysisResult(payload) return AnalysisResult(payload)
@@ -348,9 +305,7 @@ class RecipeAnalysisService:
if hasattr(tensor_image, "shape"): if hasattr(tensor_image, "shape"):
self._logger.debug( self._logger.debug(
"Tensor shape: %s, dtype: %s", "Tensor shape: %s, dtype: %s", tensor_image.shape, getattr(tensor_image, "dtype", None)
tensor_image.shape,
getattr(tensor_image, "dtype", None),
) )
import torch # type: ignore[import-not-found] import torch # type: ignore[import-not-found]

View File

@@ -250,6 +250,27 @@ class ServiceRegistry:
logger.debug(f"Created and registered {service_name}") logger.debug(f"Created and registered {service_name}")
return scanner return scanner
@classmethod
async def get_misc_scanner(cls):
"""Get or create Misc scanner instance (VAE, Upscaler)"""
service_name = "misc_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .misc_scanner import MiscScanner
scanner = await MiscScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod @classmethod
def clear_services(cls): def clear_services(cls):
"""Clear all registered services - mainly for testing""" """Clear all registered services - mainly for testing"""

View File

@@ -28,9 +28,6 @@ CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
"folder_paths", "folder_paths",
) )
# Threshold for aggressive cleanup: if file contains this many default keys, clean it up
DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
DEFAULT_SETTINGS: Dict[str, Any] = { DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "", "civitai_api_key": "",
@@ -54,7 +51,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"base_model_path_mappings": {}, "base_model_path_mappings": {},
"download_path_templates": {}, "download_path_templates": {},
"folder_paths": {}, "folder_paths": {},
"extra_folder_paths": {},
"example_images_path": "", "example_images_path": "",
"optimize_example_images": True, "optimize_example_images": True,
"auto_download_example_images": False, "auto_download_example_images": False,
@@ -67,10 +63,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"compact_mode": False, "compact_mode": False,
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
"model_name_display": "model_name", "model_name_display": "model_name",
"model_card_footer_action": "replace_preview", "model_card_footer_action": "example_images",
"update_flag_strategy": "same_base", "update_flag_strategy": "same_base",
"auto_organize_exclusions": [], "auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [],
} }
@@ -100,9 +95,6 @@ class SettingsManager:
if self._needs_initial_save: if self._needs_initial_save:
self._save_settings() self._save_settings()
self._needs_initial_save = False self._needs_initial_save = False
else:
# Clean up existing settings file by removing default values
self._cleanup_default_values_from_disk()
def _detect_standalone_mode(self) -> bool: def _detect_standalone_mode(self) -> bool:
"""Return ``True`` when running in standalone mode.""" """Return ``True`` when running in standalone mode."""
@@ -234,7 +226,7 @@ class SettingsManager:
return merged return merged
def _ensure_default_settings(self) -> None: def _ensure_default_settings(self) -> None:
"""Ensure all default settings keys exist in memory (but don't save defaults to disk)""" """Ensure all default settings keys exist"""
defaults = self._get_default_settings() defaults = self._get_default_settings()
updated_existing = False updated_existing = False
inserted_defaults = False inserted_defaults = False
@@ -263,17 +255,6 @@ class SettingsManager:
self.settings["auto_organize_exclusions"] = [] self.settings["auto_organize_exclusions"] = []
inserted_defaults = True inserted_defaults = True
if "metadata_refresh_skip_paths" in self.settings:
normalized_skip_paths = self.normalize_metadata_refresh_skip_paths(
self.settings.get("metadata_refresh_skip_paths")
)
if normalized_skip_paths != self.settings.get("metadata_refresh_skip_paths"):
self.settings["metadata_refresh_skip_paths"] = normalized_skip_paths
updated_existing = True
else:
self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True
for key, value in defaults.items(): for key, value in defaults.items():
if key == "priority_tags": if key == "priority_tags":
continue continue
@@ -284,10 +265,10 @@ class SettingsManager:
self.settings[key] = value self.settings[key] = value
inserted_defaults = True inserted_defaults = True
# Save only if existing values were normalized/updated if updated_existing or (
if updated_existing: inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
):
self._save_settings() self._save_settings()
# Note: inserted_defaults no longer triggers save - defaults stay in memory only
def _migrate_to_library_registry(self) -> None: def _migrate_to_library_registry(self) -> None:
"""Ensure settings include the multi-library registry structure.""" """Ensure settings include the multi-library registry structure."""
@@ -403,7 +384,6 @@ class SettingsManager:
active_library = libraries.get(active_name, {}) active_library = libraries.get(active_name, {})
folder_paths = copy.deepcopy(active_library.get("folder_paths", {})) folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
self.settings["folder_paths"] = folder_paths self.settings["folder_paths"] = folder_paths
self.settings["extra_folder_paths"] = copy.deepcopy(active_library.get("extra_folder_paths", {}))
self.settings["default_lora_root"] = active_library.get("default_lora_root", "") self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "") self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "")
self.settings["default_unet_root"] = active_library.get("default_unet_root", "") self.settings["default_unet_root"] = active_library.get("default_unet_root", "")
@@ -419,7 +399,6 @@ class SettingsManager:
self, self,
*, *,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -435,11 +414,6 @@ class SettingsManager:
else: else:
payload.setdefault("folder_paths", {}) payload.setdefault("folder_paths", {})
if extra_folder_paths is not None:
payload["extra_folder_paths"] = self._normalize_folder_paths(extra_folder_paths)
else:
payload.setdefault("extra_folder_paths", {})
if default_lora_root is not None: if default_lora_root is not None:
payload["default_lora_root"] = default_lora_root payload["default_lora_root"] = default_lora_root
else: else:
@@ -554,7 +528,6 @@ class SettingsManager:
self, self,
*, *,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -574,12 +547,6 @@ class SettingsManager:
library["folder_paths"] = normalized_paths library["folder_paths"] = normalized_paths
changed = True changed = True
if extra_folder_paths is not None:
normalized_extra_paths = self._normalize_folder_paths(extra_folder_paths)
if library.get("extra_folder_paths") != normalized_extra_paths:
library["extra_folder_paths"] = normalized_extra_paths
changed = True
if default_lora_root is not None and library.get("default_lora_root") != default_lora_root: if default_lora_root is not None and library.get("default_lora_root") != default_lora_root:
library["default_lora_root"] = default_lora_root library["default_lora_root"] = default_lora_root
changed = True changed = True
@@ -744,42 +711,6 @@ class SettingsManager:
self._startup_messages.append(payload) self._startup_messages.append(payload)
def _cleanup_default_values_from_disk(self) -> None:
"""Remove default values from existing settings.json to keep it clean.
Only performs cleanup if the file contains a significant number of default
values (indicating it's "bloated"). Small files (like template-based configs)
are preserved as-is to avoid unexpected changes.
"""
# Only cleanup existing files (not new ones)
if self._bootstrap_reason == "missing" or self._original_disk_payload is None:
return
defaults = self._get_default_settings()
disk_keys = set(self._original_disk_payload.keys())
# Count how many keys on disk are set to their default values
default_value_keys = set()
for key in disk_keys:
if key in CORE_USER_SETTING_KEYS:
continue # Core keys don't count as "cleanup candidates"
disk_value = self._original_disk_payload.get(key)
default_value = defaults.get(key)
# Compare using JSON serialization for complex objects
if json.dumps(disk_value, sort_keys=True, default=str) == json.dumps(default_value, sort_keys=True, default=str):
default_value_keys.add(key)
# Only cleanup if there are "many" default keys (indicating a bloated file)
# This preserves small/template-based configs while cleaning up legacy bloated files
if len(default_value_keys) >= DEFAULT_KEYS_CLEANUP_THRESHOLD:
logger.info(
"Cleaning up %d default value(s) from settings.json to keep it minimal",
len(default_value_keys)
)
self._save_settings()
# Update original payload to match what we just saved
self._original_disk_payload = self._serialize_settings_for_disk()
def _collect_configuration_warnings(self) -> None: def _collect_configuration_warnings(self) -> None:
if not self._standalone_mode: if not self._standalone_mode:
return return
@@ -831,14 +762,11 @@ class SettingsManager:
defaults['download_path_templates'] = {} defaults['download_path_templates'] = {}
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault('folder_paths', {}) defaults.setdefault('folder_paths', {})
defaults.setdefault('extra_folder_paths', {})
defaults['auto_organize_exclusions'] = [] defaults['auto_organize_exclusions'] = []
defaults['metadata_refresh_skip_paths'] = []
library_name = defaults.get("active_library") or "default" library_name = defaults.get("active_library") or "default"
default_library = self._build_library_payload( default_library = self._build_library_payload(
folder_paths=defaults.get("folder_paths", {}), folder_paths=defaults.get("folder_paths", {}),
extra_folder_paths=defaults.get("extra_folder_paths", {}),
default_lora_root=defaults.get("default_lora_root"), default_lora_root=defaults.get("default_lora_root"),
default_checkpoint_root=defaults.get("default_checkpoint_root"), default_checkpoint_root=defaults.get("default_checkpoint_root"),
default_embedding_root=defaults.get("default_embedding_root"), default_embedding_root=defaults.get("default_embedding_root"),
@@ -906,73 +834,6 @@ class SettingsManager:
self._save_settings() self._save_settings()
return exclusions return exclusions
def normalize_metadata_refresh_skip_paths(self, value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",")
)
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
candidates = value
else:
return []
paths: List[str] = []
for raw in candidates:
if isinstance(raw, str):
token = raw.replace("\\", "/").strip().strip("/")
if token:
paths.append(token)
unique_paths: List[str] = []
seen = set()
for path in paths:
if path not in seen:
seen.add(path)
unique_paths.append(path)
return unique_paths
def get_metadata_refresh_skip_paths(self) -> List[str]:
skip_paths = self.normalize_metadata_refresh_skip_paths(
self.settings.get("metadata_refresh_skip_paths")
)
if skip_paths != self.settings.get("metadata_refresh_skip_paths"):
self.settings["metadata_refresh_skip_paths"] = skip_paths
self._save_settings()
return skip_paths
def get_extra_folder_paths(self) -> Dict[str, List[str]]:
"""Get extra folder paths for the active library.
These paths are only used by LoRA Manager and not shared with ComfyUI.
Returns a dictionary with keys like 'loras', 'checkpoints', 'embeddings', 'unet'.
"""
extra_paths = self.settings.get("extra_folder_paths", {})
if not isinstance(extra_paths, dict):
return {}
return self._normalize_folder_paths(extra_paths)
def update_extra_folder_paths(
self,
extra_folder_paths: Mapping[str, Iterable[str]],
) -> None:
"""Update extra folder paths for the active library.
These paths are only used by LoRA Manager and not shared with ComfyUI.
Validates that extra paths don't overlap with other libraries' paths.
"""
active_name = self.get_active_library_name()
self._validate_folder_paths(active_name, extra_folder_paths)
normalized_paths = self._normalize_folder_paths(extra_folder_paths)
self.settings["extra_folder_paths"] = normalized_paths
self._update_active_library_entry(extra_folder_paths=normalized_paths)
self._save_settings()
logger.info("Updated extra folder paths for library '%s'", active_name)
def get_startup_messages(self) -> List[Dict[str, Any]]: def get_startup_messages(self) -> List[Dict[str, Any]]:
return [message.copy() for message in self._startup_messages] return [message.copy() for message in self._startup_messages]
@@ -1010,8 +871,6 @@ class SettingsManager:
"""Set setting value and save""" """Set setting value and save"""
if key == "auto_organize_exclusions": if key == "auto_organize_exclusions":
value = self.normalize_auto_organize_exclusions(value) value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value)
self.settings[key] = value self.settings[key] = value
portable_switch_pending = False portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool): if key == "use_portable_settings" and isinstance(value, bool):
@@ -1019,8 +878,6 @@ class SettingsManager:
self._prepare_portable_switch(value) self._prepare_portable_switch(value)
if key == 'folder_paths' and isinstance(value, Mapping): if key == 'folder_paths' and isinstance(value, Mapping):
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type] self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
elif key == 'extra_folder_paths' and isinstance(value, Mapping):
self._update_active_library_entry(extra_folder_paths=value) # type: ignore[arg-type]
elif key == 'default_lora_root': elif key == 'default_lora_root':
self._update_active_library_entry(default_lora_root=str(value)) self._update_active_library_entry(default_lora_root=str(value))
elif key == 'default_checkpoint_root': elif key == 'default_checkpoint_root':
@@ -1042,10 +899,6 @@ class SettingsManager:
self._save_settings() self._save_settings()
logger.info(f"Deleted setting: {key}") logger.info(f"Deleted setting: {key}")
def keys(self) -> Iterable[str]:
"""Return all setting keys."""
return self.settings.keys()
def _prepare_portable_switch(self, use_portable: bool) -> None: def _prepare_portable_switch(self, use_portable: bool) -> None:
"""Prepare switching the settings storage location.""" """Prepare switching the settings storage location."""
@@ -1248,12 +1101,7 @@ class SettingsManager:
self._seed_template = None self._seed_template = None
def _serialize_settings_for_disk(self) -> Dict[str, Any]: def _serialize_settings_for_disk(self) -> Dict[str, Any]:
"""Return the settings payload that should be persisted to disk. """Return the settings payload that should be persisted to disk."""
Only saves settings that differ from defaults, keeping the config file
clean and focused on user customizations. Default values are still
available at runtime via _get_default_settings().
"""
if self._bootstrap_reason == "missing": if self._bootstrap_reason == "missing":
minimal: Dict[str, Any] = {} minimal: Dict[str, Any] = {}
@@ -1267,25 +1115,7 @@ class SettingsManager:
return minimal return minimal
# Only save settings that differ from defaults return copy.deepcopy(self.settings)
defaults = self._get_default_settings()
minimal = {}
for key, value in self.settings.items():
default_value = defaults.get(key)
# Core settings are always saved (even if equal to default)
if key in CORE_USER_SETTING_KEYS:
minimal[key] = copy.deepcopy(value)
# Complex objects need deep comparison
elif isinstance(value, (dict, list)) and default_value is not None:
if json.dumps(value, sort_keys=True, default=str) != json.dumps(default_value, sort_keys=True, default=str):
minimal[key] = copy.deepcopy(value)
# Simple values use direct comparison
elif value != default_value:
minimal[key] = copy.deepcopy(value)
return minimal
def get_libraries(self) -> Dict[str, Dict[str, Any]]: def get_libraries(self) -> Dict[str, Dict[str, Any]]:
"""Return a copy of the registered libraries.""" """Return a copy of the registered libraries."""
@@ -1332,7 +1162,6 @@ class SettingsManager:
library_name: str, library_name: str,
*, *,
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -1349,15 +1178,11 @@ class SettingsManager:
if folder_paths is not None: if folder_paths is not None:
self._validate_folder_paths(name, folder_paths) self._validate_folder_paths(name, folder_paths)
if extra_folder_paths is not None:
self._validate_folder_paths(name, extra_folder_paths)
libraries = self.settings.setdefault("libraries", {}) libraries = self.settings.setdefault("libraries", {})
existing = libraries.get(name, {}) existing = libraries.get(name, {})
payload = self._build_library_payload( payload = self._build_library_payload(
folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"), folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"),
extra_folder_paths=extra_folder_paths if extra_folder_paths is not None else existing.get("extra_folder_paths"),
default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"), default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"),
default_checkpoint_root=( default_checkpoint_root=(
default_checkpoint_root default_checkpoint_root
@@ -1396,7 +1221,6 @@ class SettingsManager:
library_name: str, library_name: str,
*, *,
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: str = "", default_lora_root: str = "",
default_checkpoint_root: str = "", default_checkpoint_root: str = "",
default_unet_root: str = "", default_unet_root: str = "",
@@ -1413,7 +1237,6 @@ class SettingsManager:
return self.upsert_library( return self.upsert_library(
library_name, library_name,
folder_paths=folder_paths, folder_paths=folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root, default_unet_root=default_unet_root,
@@ -1472,7 +1295,6 @@ class SettingsManager:
self, self,
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
*, *,
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None, default_unet_root: Optional[str] = None,
@@ -1484,7 +1306,6 @@ class SettingsManager:
self.upsert_library( self.upsert_library(
active_name, active_name,
folder_paths=folder_paths, folder_paths=folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root, default_unet_root=default_unet_root,

View File

@@ -69,9 +69,7 @@ class TagFTSIndex:
_DEFAULT_FILENAME = "tag_fts.sqlite" _DEFAULT_FILENAME = "tag_fts.sqlite"
_CSV_FILENAME = "danbooru_e621_merged.csv" _CSV_FILENAME = "danbooru_e621_merged.csv"
def __init__( def __init__(self, db_path: Optional[str] = None, csv_path: Optional[str] = None) -> None:
self, db_path: Optional[str] = None, csv_path: Optional[str] = None
) -> None:
"""Initialize the FTS index. """Initialize the FTS index.
Args: Args:
@@ -94,9 +92,7 @@ class TagFTSIndex:
if directory: if directory:
os.makedirs(directory, exist_ok=True) os.makedirs(directory, exist_ok=True)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning("Could not create FTS index directory %s: %s", directory, exc)
"Could not create FTS index directory %s: %s", directory, exc
)
def _resolve_default_db_path(self) -> str: def _resolve_default_db_path(self) -> str:
"""Resolve the default database path.""" """Resolve the default database path."""
@@ -177,15 +173,13 @@ class TagFTSIndex:
# Set schema version # Set schema version
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION)), ("schema_version", str(SCHEMA_VERSION))
) )
conn.commit() conn.commit()
self._schema_initialized = True self._schema_initialized = True
self._needs_rebuild = needs_rebuild self._needs_rebuild = needs_rebuild
logger.debug( logger.debug("Tag FTS index schema initialized at %s", self._db_path)
"Tag FTS index schema initialized at %s", self._db_path
)
finally: finally:
conn.close() conn.close()
except Exception as exc: except Exception as exc:
@@ -212,20 +206,13 @@ class TagFTSIndex:
row = cursor.fetchone() row = cursor.fetchone()
if not row: if not row:
# Old schema without version, needs rebuild # Old schema without version, needs rebuild
logger.info( logger.info("Migrating tag FTS index to schema version %d (adding alias support)", SCHEMA_VERSION)
"Migrating tag FTS index to schema version %d (adding alias support)",
SCHEMA_VERSION,
)
self._drop_old_tables(conn) self._drop_old_tables(conn)
return True return True
current_version = int(row[0]) current_version = int(row[0])
if current_version < SCHEMA_VERSION: if current_version < SCHEMA_VERSION:
logger.info( logger.info("Migrating tag FTS index from version %d to %d", current_version, SCHEMA_VERSION)
"Migrating tag FTS index from version %d to %d",
current_version,
SCHEMA_VERSION,
)
self._drop_old_tables(conn) self._drop_old_tables(conn)
return True return True
@@ -259,9 +246,7 @@ class TagFTSIndex:
return return
if not os.path.exists(self._csv_path): if not os.path.exists(self._csv_path):
logger.warning( logger.warning("CSV file not found at %s, cannot build tag index", self._csv_path)
"CSV file not found at %s, cannot build tag index", self._csv_path
)
return return
self._indexing_in_progress = True self._indexing_in_progress = True
@@ -329,24 +314,22 @@ class TagFTSIndex:
# Update metadata # Update metadata
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("last_build_time", str(time.time())), ("last_build_time", str(time.time()))
) )
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("tag_count", str(total_inserted)), ("tag_count", str(total_inserted))
) )
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
("schema_version", str(SCHEMA_VERSION)), ("schema_version", str(SCHEMA_VERSION))
) )
conn.commit() conn.commit()
elapsed = time.time() - start_time elapsed = time.time() - start_time
logger.info( logger.info(
"Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs", "Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs",
total_inserted, total_inserted, tags_with_aliases, elapsed
tags_with_aliases,
elapsed,
) )
finally: finally:
conn.close() conn.close()
@@ -367,7 +350,7 @@ class TagFTSIndex:
# Insert into tags table (with aliases) # Insert into tags table (with aliases)
conn.executemany( conn.executemany(
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)", "INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
rows, rows
) )
# Build a map of tag_name -> aliases for FTS insertion # Build a map of tag_name -> aliases for FTS insertion
@@ -379,7 +362,7 @@ class TagFTSIndex:
placeholders = ",".join("?" * len(tag_names)) placeholders = ",".join("?" * len(tag_names))
cursor = conn.execute( cursor = conn.execute(
f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})", f"SELECT rowid, tag_name FROM tags WHERE tag_name IN ({placeholders})",
tag_names, tag_names
) )
# Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...") # Build FTS rows with (rowid, searchable_text) = (tags.rowid, "tag_name alias1 alias2 ...")
@@ -396,17 +379,13 @@ class TagFTSIndex:
alias = alias[1:] # Remove leading slash alias = alias[1:] # Remove leading slash
if alias: if alias:
alias_parts.append(alias) alias_parts.append(alias)
searchable_text = ( searchable_text = f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
)
else: else:
searchable_text = tag_name searchable_text = tag_name
fts_rows.append((rowid, searchable_text)) fts_rows.append((rowid, searchable_text))
if fts_rows: if fts_rows:
conn.executemany( conn.executemany("INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows)
"INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows
)
def ensure_ready(self) -> bool: def ensure_ready(self) -> bool:
"""Ensure the index is ready, building if necessary. """Ensure the index is ready, building if necessary.
@@ -441,28 +420,21 @@ class TagFTSIndex:
self, self,
query: str, query: str,
categories: Optional[List[int]] = None, categories: Optional[List[int]] = None,
limit: int = 20, limit: int = 20
offset: int = 0,
) -> List[Dict]: ) -> List[Dict]:
"""Search tags using FTS5 with prefix matching. """Search tags using FTS5 with prefix matching.
Supports alias search: if the query matches an alias rather than Supports alias search: if the query matches an alias rather than
the tag_name, the result will include a "matched_alias" field. the tag_name, the result will include a "matched_alias" field.
Ranking is based on a combination of:
1. FTS5 bm25 relevance score (how well the text matches)
2. Post count (popularity)
3. Exact prefix match boost (tag_name starts with query)
Args: Args:
query: The search query string. query: The search query string.
categories: Optional list of category IDs to filter by. categories: Optional list of category IDs to filter by.
limit: Maximum number of results to return. limit: Maximum number of results to return.
offset: Number of results to skip.
Returns: Returns:
List of dictionaries with tag_name, category, post_count, List of dictionaries with tag_name, category, post_count,
rank_score, and optionally matched_alias. and optionally matched_alias.
""" """
# Ensure index is ready (lazy initialization) # Ensure index is ready (lazy initialization)
if not self.ensure_ready(): if not self.ensure_ready():
@@ -478,67 +450,35 @@ class TagFTSIndex:
if not fts_query: if not fts_query:
return [] return []
query_lower = query.lower().strip()
try: try:
with self._lock: with self._lock:
conn = self._connect(readonly=True) conn = self._connect(readonly=True)
try: try:
# Build the SQL query with bm25 ranking # Build the SQL query - now also fetch aliases for matched_alias detection
# FTS5 bm25() returns negative scores, lower is better # Use subquery for category filter to ensure FTS is evaluated first
# We use -bm25() to get higher=better scores
# Weights: -100.0 for exact matches, 1.0 for others
# Add LOG10(post_count) weighting to boost popular tags
# Use CASE to boost tag_name prefix matches above alias matches
if categories: if categories:
placeholders = ",".join("?" * len(categories)) placeholders = ",".join("?" * len(categories))
sql = f""" sql = f"""
SELECT t.tag_name, t.category, t.post_count, t.aliases, SELECT t.tag_name, t.category, t.post_count, t.aliases
CASE FROM tags t
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1 WHERE t.rowid IN (
ELSE 0 SELECT rowid FROM tag_fts WHERE searchable_text MATCH ?
END AS is_tag_name_match, )
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
AND t.category IN ({placeholders}) AND t.category IN ({placeholders})
ORDER BY is_tag_name_match DESC, rank_score DESC ORDER BY t.post_count DESC
LIMIT ? OFFSET ? LIMIT ?
""" """
# Escape special LIKE characters and add wildcard params = [fts_query] + categories + [limit]
query_escaped = (
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = (
[query_escaped + "%", fts_query]
+ categories
+ [limit, offset]
)
else: else:
sql = """ sql = """
SELECT t.tag_name, t.category, t.post_count, t.aliases, SELECT t.tag_name, t.category, t.post_count, t.aliases
CASE FROM tag_fts f
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1 JOIN tags t ON f.rowid = t.rowid
ELSE 0 WHERE f.searchable_text MATCH ?
END AS is_tag_name_match, ORDER BY t.post_count DESC
bm25(tag_fts, -100.0, 1.0, 1.0) + LOG10(t.post_count + 1) * 10.0 AS rank_score LIMIT ?
FROM tag_fts
JOIN tags t ON tag_fts.rowid = t.rowid
WHERE tag_fts.searchable_text MATCH ?
ORDER BY is_tag_name_match DESC, rank_score DESC
LIMIT ? OFFSET ?
""" """
query_escaped = ( params = [fts_query, limit]
query_lower.lstrip("/")
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
params = [query_escaped + "%", fts_query, limit, offset]
cursor = conn.execute(sql, params) cursor = conn.execute(sql, params)
results = [] results = []
@@ -547,17 +487,8 @@ class TagFTSIndex:
"tag_name": row[0], "tag_name": row[0],
"category": row[1], "category": row[1],
"post_count": row[2], "post_count": row[2],
"is_tag_name_match": row[4] == 1,
"rank_score": row[5],
} }
# Set is_exact_prefix based on tag_name match
tag_name = row[0]
if tag_name.lower().startswith(query_lower.lstrip("/")):
result["is_exact_prefix"] = True
else:
result["is_exact_prefix"] = result["is_tag_name_match"]
# Check if search matched an alias rather than the tag_name # Check if search matched an alias rather than the tag_name
matched_alias = self._find_matched_alias(query, row[0], row[3]) matched_alias = self._find_matched_alias(query, row[0], row[3])
if matched_alias: if matched_alias:
@@ -571,9 +502,7 @@ class TagFTSIndex:
logger.debug("Tag FTS search error for query '%s': %s", query, exc) logger.debug("Tag FTS search error for query '%s': %s", query, exc)
return [] return []
def _find_matched_alias( def _find_matched_alias(self, query: str, tag_name: str, aliases_str: str) -> Optional[str]:
self, query: str, tag_name: str, aliases_str: str
) -> Optional[str]:
"""Find which alias matched the query, if any. """Find which alias matched the query, if any.
Args: Args:

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Dict, List, Optional, Protocol, Sequence from typing import Any, Dict, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService from ..metadata_sync_service import MetadataSyncService
from ...utils.metadata_manager import MetadataManager from ...utils.metadata_manager import MetadataManager
@@ -43,21 +43,14 @@ class BulkMetadataRefreshUseCase:
total_models = len(cache.raw_data) total_models = len(cache.raw_data)
enable_metadata_archive_db = self._settings.get("enable_metadata_archive_db", False) enable_metadata_archive_db = self._settings.get("enable_metadata_archive_db", False)
skip_paths = self._settings.get("metadata_refresh_skip_paths", [])
to_process: Sequence[Dict[str, Any]] = [ to_process: Sequence[Dict[str, Any]] = [
model model
for model in cache.raw_data for model in cache.raw_data
if not model.get("skip_metadata_refresh", False) if model.get("sha256")
and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
and (not model.get("civitai") or not model["civitai"].get("id")) and (not model.get("civitai") or not model["civitai"].get("id"))
and not (
# Skip models confirmed not on CivitAI when no need to retry
model.get("from_civitai") is False
and model.get("civitai_deleted") is True
and ( and (
not enable_metadata_archive_db (enable_metadata_archive_db and not model.get("db_checked", False))
or model.get("db_checked", False) or (not enable_metadata_archive_db and model.get("from_civitai") is True)
)
) )
] ]
@@ -84,36 +77,6 @@ class BulkMetadataRefreshUseCase:
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models} return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
try: try:
original_name = model.get("model_name") original_name = model.get("model_name")
# Handle lazy hash calculation for models with pending hash status
sha256 = model.get("sha256", "")
hash_status = model.get("hash_status", "completed")
file_path = model.get("file_path")
if not sha256 and hash_status == "pending" and file_path:
self._logger.info(f"Calculating pending hash for {file_path}")
# Check if scanner has calculate_hash_for_model method (CheckpointScanner)
calculate_hash_method = getattr(self._service.scanner, "calculate_hash_for_model", None)
if calculate_hash_method:
sha256 = await calculate_hash_method(file_path)
if sha256:
model["sha256"] = sha256
model["hash_status"] = "completed"
else:
self._logger.error(f"Failed to calculate hash for {file_path}")
processed += 1
continue
else:
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
processed += 1
continue
# Skip models without valid hash
if not model.get("sha256"):
self._logger.warning(f"Skipping model without hash: {file_path}")
processed += 1
continue
await MetadataManager.hydrate_model_data(model) await MetadataManager.hydrate_model_data(model)
result, _ = await self._metadata_sync.fetch_and_update_model( result, _ = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"], sha256=model["sha256"],
@@ -152,21 +115,6 @@ class BulkMetadataRefreshUseCase:
return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models} return {"success": True, "message": message, "processed": processed, "updated": success, "total": total_models}
@staticmethod
def _is_in_skip_path(folder: str, skip_paths: List[str]) -> bool:
if not skip_paths or not folder:
return False
normalized = folder.replace("\\", "/").strip("/")
if not normalized:
return False
for sp in skip_paths:
nsp = sp.replace("\\", "/").strip("/")
if not nsp:
continue
if normalized == nsp or normalized.startswith(nsp + "/"):
return True
return False
async def execute_with_error_handling( async def execute_with_error_handling(
self, self,
*, *,

Some files were not shown because too many files have changed in this diff Show More