mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-26 07:35:44 -03:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceeab0c998 | ||
|
|
3b001a6cd8 | ||
|
|
95e5bc26d1 | ||
|
|
de3d0571f8 | ||
|
|
6f2a01dc86 | ||
|
|
c5c1b8fd2a | ||
|
|
e97648c70b | ||
|
|
8b85e083e2 | ||
|
|
9112cd3b62 | ||
|
|
7df4e8d037 | ||
|
|
4000b7f7e7 | ||
|
|
76c15105e6 | ||
|
|
b11c90e19b | ||
|
|
9f5d2d0c18 | ||
|
|
a0dc5229f4 | ||
|
|
61c31ecbd0 | ||
|
|
1ae1b0d607 | ||
|
|
8dd849892d | ||
|
|
03e1fa75c5 | ||
|
|
fefcaa4a45 | ||
|
|
701a6a6c44 | ||
|
|
0ef414d17e | ||
|
|
75dccaef87 | ||
|
|
7e87ec9521 | ||
|
|
46522edb1b | ||
|
|
2dae4c1291 | ||
|
|
a32325402e | ||
|
|
70c150bd80 | ||
|
|
9e81c33f8a | ||
|
|
22c0dbd734 | ||
|
|
d0c58472be | ||
|
|
b3c530bf36 | ||
|
|
05ebd7493d | ||
|
|
90986bd795 | ||
|
|
b5a0725d2c | ||
|
|
ef38bda04f | ||
|
|
58713ea6e0 | ||
|
|
8b91920058 | ||
|
|
ee466113d5 | ||
|
|
f86651652c | ||
|
|
c89d4dae85 | ||
|
|
55a18d401b | ||
|
|
7570936c75 | ||
|
|
4fcf641d57 | ||
|
|
5c29e26c4e | ||
|
|
ee765a6d22 | ||
|
|
c02f603ed2 | ||
|
|
ee84b30023 | ||
|
|
97979d9e7c | ||
|
|
cda271890a | ||
|
|
2fbe6c8843 | ||
|
|
4fb07370dd | ||
|
|
43f6bfab36 | ||
|
|
a802a89ff9 | ||
|
|
343dd91e4b | ||
|
|
3756f88368 | ||
|
|
acc625ead3 | ||
|
|
f402505f97 | ||
|
|
4d8113464c | ||
|
|
1ed503a6b5 | ||
|
|
d67914e095 |
153
.docs/batch-import-design.md
Normal file
153
.docs/batch-import-design.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ model_cache/
|
||||
|
||||
# agent
|
||||
.opencode/
|
||||
.claude/
|
||||
|
||||
# Vue widgets development cache (but keep build output)
|
||||
vue-widgets/node_modules/
|
||||
|
||||
464
.specs/metadata.schema.json
Normal file
464
.specs/metadata.schema.json
Normal file
@@ -0,0 +1,464 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ npm run test:coverage # Generate coverage report
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
- Run `python scripts/sync_translation_keys.py` after UI string updates
|
||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||
- Symlinks require normalized paths
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
11
README.md
11
README.md
@@ -59,6 +59,7 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
|
||||
### v1.0.0
|
||||
* **Extra Folder Paths Support** - Added support for additional model root paths exclusive to LoRA Manager. This allows loading LoRAs from extra locations outside ComfyUI's standard folders, helping avoid performance issues when working with large model libraries.
|
||||
* **Settings UI Overhaul** - Redesigned the Settings interface with a more organized layout, making it easier to find and configure application settings.
|
||||
* **Lazy Hash Computation** - Implemented lazy hash calculation for large model files (checkpoints and diffusion models). Hashes are now computed only when strictly necessary, minimizing redundant disk I/O and significantly accelerating application initialization.
|
||||
* **Milestone & Supporter Recognition** - Updated the Supporter window to show appreciation for all project supporters as this v1.0.0 milestone is reached. Great thanks to the community for the ongoing support!
|
||||
* **Bug Fixes & UX Enhancements** - Various bug fixes and user experience improvements for a smoother workflow.
|
||||
|
||||
@@ -178,6 +179,8 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
|
||||
- Context menu for quick actions
|
||||
- Custom notes and usage tips
|
||||
- Multi-folder support
|
||||
- Configurable mature blur threshold (`PG13` / `R` / `X` / `XXX`, default `R+`)
|
||||
- Example: setting threshold to `PG13` blurs `PG13`, `R`, `X`, and `XXX` previews when blur is enabled
|
||||
- Visual progress indicators during initialization
|
||||
|
||||
---
|
||||
@@ -193,7 +196,7 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
|
||||
|
||||
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
|
||||
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
|
||||
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v1.0.0/lora_manager_portable.7z)
|
||||
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
|
||||
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
|
||||
@@ -320,6 +323,12 @@ npm run test:coverage
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[metadata.json Schema Documentation](docs/metadata-json-schema.md)** — Complete reference for the `.metadata.json` sidecar file format, including all fields, types, and examples for LoRA, Checkpoint, and Embedding models.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for your interest in contributing to ComfyUI LoRA Manager! As this project is currently in its early stages and undergoing rapid development and refactoring, we are temporarily not accepting pull requests.
|
||||
|
||||
20
__init__.py
20
__init__.py
@@ -1,6 +1,8 @@
|
||||
try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.lora_manager import LoraManager
|
||||
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.prompt import PromptLM
|
||||
from .py.nodes.text import TextLM
|
||||
@@ -27,12 +29,12 @@ except (
|
||||
PromptLM = importlib.import_module("py.nodes.prompt").PromptLM
|
||||
TextLM = importlib.import_module("py.nodes.text").TextLM
|
||||
LoraManager = importlib.import_module("py.lora_manager").LoraManager
|
||||
LoraLoaderLM = importlib.import_module(
|
||||
"py.nodes.lora_loader"
|
||||
).LoraLoaderLM
|
||||
LoraTextLoaderLM = importlib.import_module(
|
||||
"py.nodes.lora_loader"
|
||||
).LoraTextLoaderLM
|
||||
LoraLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraLoaderLM
|
||||
LoraTextLoaderLM = importlib.import_module("py.nodes.lora_loader").LoraTextLoaderLM
|
||||
CheckpointLoaderLM = importlib.import_module(
|
||||
"py.nodes.checkpoint_loader"
|
||||
).CheckpointLoaderLM
|
||||
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
|
||||
TriggerWordToggleLM = importlib.import_module(
|
||||
"py.nodes.trigger_word_toggle"
|
||||
).TriggerWordToggleLM
|
||||
@@ -49,9 +51,7 @@ except (
|
||||
LoraRandomizerLM = importlib.import_module(
|
||||
"py.nodes.lora_randomizer"
|
||||
).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
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -59,6 +59,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
TextLM.NAME: TextLM,
|
||||
LoraLoaderLM.NAME: LoraLoaderLM,
|
||||
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
||||
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
|
||||
UNETLoaderLM.NAME: UNETLoaderLM,
|
||||
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
||||
LoraStackerLM.NAME: LoraStackerLM,
|
||||
SaveImageLM.NAME: SaveImageLM,
|
||||
|
||||
363
docs/metadata-json-schema.md
Normal file
363
docs/metadata-json-schema.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# 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
|
||||
105
locales/de.json
105
locales/de.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Nach oben",
|
||||
"settings": "Einstellungen",
|
||||
"help": "Hilfe",
|
||||
"add": "Hinzufügen"
|
||||
"add": "Hinzufügen",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Wird geladen...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||
"baseModel": "Basis-Modell",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Modelltypen",
|
||||
"license": "Lizenz",
|
||||
"noCreditRequired": "Kein Credit erforderlich",
|
||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
|
||||
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
|
||||
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
|
||||
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern"
|
||||
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
|
||||
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
|
||||
"deleteAll": "Alle Modelle löschen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"clear": "Auswahl löschen",
|
||||
"skipMetadataRefreshCount": "Überspringen({count} Modelle)",
|
||||
"resumeMetadataRefreshCount": "Fortsetzen({count} Modelle)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "Stammverzeichnis",
|
||||
"browseFolders": "Ordner durchsuchen:",
|
||||
"downloadAndSaveRecipe": "Herunterladen & Rezept speichern",
|
||||
"importRecipeOnly": "Nur Rezept importieren",
|
||||
"importAndDownload": "Importieren & Herunterladen",
|
||||
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
|
||||
"saveRecipe": "Rezept speichern",
|
||||
"loraCountInfo": "({existing}/{total} in Bibliothek)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "Wenigste"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
|
||||
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "Basis-Modell aktualisieren",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Fehlende LoRAs herunterladen",
|
||||
"message": "{uniqueCount} einzigartige fehlende LoRAs gefunden (von insgesamt {totalCount} in ausgewählten Rezepten).",
|
||||
"previewTitle": "Zu herunterladende LoRAs:",
|
||||
"moreItems": "...und {count} weitere",
|
||||
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
|
||||
"downloadButton": "{count} LoRA(s) herunterladen"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Lokale Beispielbilder",
|
||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "Fehler beim Laden der {modelType}s: {message}",
|
||||
"refreshComplete": "Aktualisierung abgeschlossen",
|
||||
"refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}",
|
||||
"syncComplete": "Synchronisation abgeschlossen",
|
||||
"syncFailed": "Fehler beim Synchronisieren der Rezepte: {message}",
|
||||
"updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}",
|
||||
"updateError": "Fehler beim Aktualisieren des Rezepts: {message}",
|
||||
"nameSaved": "Rezept \"{name}\" erfolgreich gespeichert",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "Verarbeitungsfehler: {message}",
|
||||
"folderBrowserError": "Fehler beim Laden des Ordner-Browsers: {message}",
|
||||
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "Import fehlgeschlagen: {message}",
|
||||
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
|
||||
"folderTreeError": "Fehler beim Laden des Ordnerbaums"
|
||||
"folderTreeError": "Fehler beim Laden des Ordnerbaums",
|
||||
"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}",
|
||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Keine Modelle ausgewählt",
|
||||
|
||||
105
locales/en.json
105
locales/en.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Back to top",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"add": "Add"
|
||||
"add": "Add",
|
||||
"close": "Close"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "Blur NSFW Content",
|
||||
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
|
||||
"showOnlySfw": "Show Only SFW Results",
|
||||
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching"
|
||||
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
|
||||
"matureBlurThreshold": "Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "PG13 and above",
|
||||
"r": "R and above (default)",
|
||||
"x": "X and above",
|
||||
"xxx": "XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Autoplay Videos on Hover",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
|
||||
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
|
||||
"deleteAll": "Delete Selected Models",
|
||||
"downloadMissingLoras": "Download Missing LoRAs",
|
||||
"clear": "Clear Selection",
|
||||
"skipMetadataRefreshCount": "Skip ({count} models)",
|
||||
"resumeMetadataRefreshCount": "Resume ({count} models)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "Root",
|
||||
"browseFolders": "Browse Folders:",
|
||||
"downloadAndSaveRecipe": "Download & Save Recipe",
|
||||
"importRecipeOnly": "Import Recipe Only",
|
||||
"importAndDownload": "Import & Download",
|
||||
"downloadMissingLoras": "Download Missing LoRAs",
|
||||
"saveRecipe": "Save Recipe",
|
||||
"loraCountInfo": "({existing}/{total} in library)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "Least"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "Failed to repair recipe: {message}",
|
||||
"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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "Update Base Model",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Download Missing LoRAs",
|
||||
"message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).",
|
||||
"previewTitle": "LoRAs to download:",
|
||||
"moreItems": "...and {count} more",
|
||||
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
|
||||
"downloadButton": "Download {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Local Example Images",
|
||||
"message": "No local example images found for this model. View options:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "Failed to load {modelType}s: {message}",
|
||||
"refreshComplete": "Refresh complete",
|
||||
"refreshFailed": "Failed to refresh recipes: {message}",
|
||||
"syncComplete": "Sync complete",
|
||||
"syncFailed": "Failed to sync recipes: {message}",
|
||||
"updateFailed": "Failed to update recipe: {error}",
|
||||
"updateError": "Error updating recipe: {message}",
|
||||
"nameSaved": "Recipe \"{name}\" saved successfully",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "Processing error: {message}",
|
||||
"folderBrowserError": "Error loading folder browser: {message}",
|
||||
"recipeSaveFailed": "Failed to save recipe: {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "Import failed: {message}",
|
||||
"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}",
|
||||
"noRecipesSelected": "No recipes selected",
|
||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No models selected",
|
||||
@@ -1671,4 +1766,4 @@
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
105
locales/es.json
105
locales/es.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Volver arriba",
|
||||
"settings": "Configuración",
|
||||
"help": "Ayuda",
|
||||
"add": "Añadir"
|
||||
"add": "Añadir",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Cargando...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||
"baseModel": "Modelo base",
|
||||
"modelTags": "Etiquetas (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Tipos de modelos",
|
||||
"license": "Licencia",
|
||||
"noCreditRequired": "Sin crédito requerido",
|
||||
"allowSellingGeneratedContent": "Venta permitida",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "Difuminar contenido NSFW",
|
||||
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
|
||||
"showOnlySfw": "Mostrar solo resultados SFW",
|
||||
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar"
|
||||
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
|
||||
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
|
||||
"deleteAll": "Eliminar todos los modelos",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"clear": "Limpiar selección",
|
||||
"skipMetadataRefreshCount": "Omitir({count} modelos)",
|
||||
"resumeMetadataRefreshCount": "Reanudar({count} modelos)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "Raíz",
|
||||
"browseFolders": "Explorar carpetas:",
|
||||
"downloadAndSaveRecipe": "Descargar y guardar receta",
|
||||
"importRecipeOnly": "Importar solo la receta",
|
||||
"importAndDownload": "Importar y descargar",
|
||||
"downloadMissingLoras": "Descargar LoRAs faltantes",
|
||||
"saveRecipe": "Guardar receta",
|
||||
"loraCountInfo": "({existing}/{total} en la biblioteca)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "Menos"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "Error al reparar la receta: {message}",
|
||||
"missingId": "No se puede reparar la receta: falta el ID de la receta"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "Actualizar modelo base",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Descargar LoRAs faltantes",
|
||||
"message": "Se encontraron {uniqueCount} LoRAs faltantes únicos (de {totalCount} en total entre las recetas seleccionadas).",
|
||||
"previewTitle": "LoRAs para descargar:",
|
||||
"moreItems": "...y {count} más",
|
||||
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
|
||||
"downloadButton": "Descargar {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Imágenes de ejemplo locales",
|
||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "Error al cargar {modelType}s: {message}",
|
||||
"refreshComplete": "Actualización completa",
|
||||
"refreshFailed": "Error al actualizar recetas: {message}",
|
||||
"syncComplete": "Sincronización completa",
|
||||
"syncFailed": "Error al sincronizar recetas: {message}",
|
||||
"updateFailed": "Error al actualizar receta: {error}",
|
||||
"updateError": "Error actualizando receta: {message}",
|
||||
"nameSaved": "Receta \"{name}\" guardada exitosamente",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "Error de procesamiento: {message}",
|
||||
"folderBrowserError": "Error cargando explorador de carpetas: {message}",
|
||||
"recipeSaveFailed": "Error al guardar receta: {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "Importación falló: {message}",
|
||||
"folderTreeFailed": "Error al cargar árbol de carpetas",
|
||||
"folderTreeError": "Error cargando árbol de carpetas"
|
||||
"folderTreeError": "Error cargando árbol de carpetas",
|
||||
"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}",
|
||||
"noRecipesSelected": "No se han seleccionado recetas",
|
||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No hay modelos seleccionados",
|
||||
|
||||
105
locales/fr.json
105
locales/fr.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Retour en haut",
|
||||
"settings": "Paramètres",
|
||||
"help": "Aide",
|
||||
"add": "Ajouter"
|
||||
"add": "Ajouter",
|
||||
"close": "Fermer"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Chargement...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Nom du préréglage...",
|
||||
"baseModel": "Modèle de base",
|
||||
"modelTags": "Tags (Top 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Types de modèles",
|
||||
"license": "Licence",
|
||||
"noCreditRequired": "Crédit non requis",
|
||||
"allowSellingGeneratedContent": "Vente autorisée",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "Flouter le contenu NSFW",
|
||||
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
|
||||
"showOnlySfw": "Afficher uniquement les résultats SFW",
|
||||
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche"
|
||||
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Lecture automatique vidéo au survol",
|
||||
@@ -574,6 +583,7 @@
|
||||
"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",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"clear": "Effacer la sélection",
|
||||
"skipMetadataRefreshCount": "Ignorer({count} modèles)",
|
||||
"resumeMetadataRefreshCount": "Reprendre({count} modèles)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "Racine",
|
||||
"browseFolders": "Parcourir les dossiers :",
|
||||
"downloadAndSaveRecipe": "Télécharger et sauvegarder la recipe",
|
||||
"importRecipeOnly": "Importer uniquement la recette",
|
||||
"importAndDownload": "Importer et télécharger",
|
||||
"downloadMissingLoras": "Télécharger les LoRAs manquants",
|
||||
"saveRecipe": "Sauvegarder la recipe",
|
||||
"loraCountInfo": "({existing}/{total} dans la bibliothèque)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "Moins"
|
||||
},
|
||||
"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",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "Échec de la réparation de la recette : {message}",
|
||||
"missingId": "Impossible de réparer la recette : ID de recette manquant"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "Mettre à jour le modèle de base",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Télécharger les LoRAs manquants",
|
||||
"message": "{uniqueCount} LoRAs manquants uniques trouvés (sur un total de {totalCount} dans les recettes sélectionnées).",
|
||||
"previewTitle": "LoRAs à télécharger :",
|
||||
"moreItems": "...et {count} de plus",
|
||||
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
|
||||
"downloadButton": "Télécharger {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Images d'exemple locales",
|
||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "Échec du chargement des {modelType}s : {message}",
|
||||
"refreshComplete": "Actualisation terminée",
|
||||
"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}",
|
||||
"updateError": "Erreur lors de la mise à jour de la recipe : {message}",
|
||||
"nameSaved": "Recipe \"{name}\" sauvegardée avec succès",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "Erreur de traitement : {message}",
|
||||
"folderBrowserError": "Erreur lors du chargement du navigateur de dossiers : {message}",
|
||||
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "Échec de l'importation : {message}",
|
||||
"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": "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}",
|
||||
"noRecipesSelected": "Aucune recette sélectionnée",
|
||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Aucun modèle sélectionné",
|
||||
|
||||
105
locales/he.json
105
locales/he.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "חזרה למעלה",
|
||||
"settings": "הגדרות",
|
||||
"help": "עזרה",
|
||||
"add": "הוספה"
|
||||
"add": "הוספה",
|
||||
"close": "סגור"
|
||||
},
|
||||
"status": {
|
||||
"loading": "טוען...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||
"baseModel": "מודל בסיס",
|
||||
"modelTags": "תגיות (20 המובילות)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "סוגי מודלים",
|
||||
"license": "רישיון",
|
||||
"noCreditRequired": "ללא קרדיט נדרש",
|
||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "טשטש תוכן NSFW",
|
||||
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
|
||||
"showOnlySfw": "הצג רק תוצאות SFW",
|
||||
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש"
|
||||
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
|
||||
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
|
||||
"deleteAll": "מחק את כל המודלים",
|
||||
"downloadMissingLoras": "הורדת LoRAs חסרים",
|
||||
"clear": "נקה בחירה",
|
||||
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
|
||||
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "שורש",
|
||||
"browseFolders": "דפדף בתיקיות:",
|
||||
"downloadAndSaveRecipe": "הורד ושמור מתכון",
|
||||
"importRecipeOnly": "יבא רק מתכון",
|
||||
"importAndDownload": "יבא והורד",
|
||||
"downloadMissingLoras": "הורד LoRAs חסרים",
|
||||
"saveRecipe": "שמור מתכון",
|
||||
"loraCountInfo": "({existing}/{total} בספרייה)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "הכי פחות"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מתכונים"
|
||||
"title": "רענן רשימת מתכונים",
|
||||
"quick": "סנכרן שינויים",
|
||||
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
|
||||
"full": "בנה מטמון מחדש",
|
||||
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
|
||||
},
|
||||
"filteredByLora": "מסונן לפי LoRA",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "תיקון המתכון נכשל: {message}",
|
||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "עדכן מודל בסיס",
|
||||
"cancel": "ביטול"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "הורדת LoRAs חסרים",
|
||||
"message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).",
|
||||
"previewTitle": "LoRAs להורדה:",
|
||||
"moreItems": "...ועוד {count}",
|
||||
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||
"downloadButton": "הורד {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "תמונות דוגמה מקומיות",
|
||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "טעינת {modelType}s נכשלה: {message}",
|
||||
"refreshComplete": "הרענון הושלם",
|
||||
"refreshFailed": "רענון המתכונים נכשל: {message}",
|
||||
"syncComplete": "הסנכרון הושלם",
|
||||
"syncFailed": "סנכרון המתכונים נכשל: {message}",
|
||||
"updateFailed": "עדכון המתכון נכשל: {error}",
|
||||
"updateError": "שגיאה בעדכון המתכון: {message}",
|
||||
"nameSaved": "המתכון \"{name}\" נשמר בהצלחה",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "שגיאת עיבוד: {message}",
|
||||
"folderBrowserError": "שגיאה בטעינת דפדפן התיקיות: {message}",
|
||||
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "הייבוא נכשל: {message}",
|
||||
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
|
||||
"folderTreeError": "שגיאה בטעינת עץ התיקיות"
|
||||
"folderTreeError": "שגיאה בטעינת עץ התיקיות",
|
||||
"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}",
|
||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "לא נבחרו מודלים",
|
||||
|
||||
105
locales/ja.json
105
locales/ja.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "トップへ戻る",
|
||||
"settings": "設定",
|
||||
"help": "ヘルプ",
|
||||
"add": "追加"
|
||||
"add": "追加",
|
||||
"close": "閉じる"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "プリセット名...",
|
||||
"baseModel": "ベースモデル",
|
||||
"modelTags": "タグ(上位20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "モデルタイプ",
|
||||
"license": "ライセンス",
|
||||
"noCreditRequired": "クレジット不要",
|
||||
"allowSellingGeneratedContent": "販売許可",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFWコンテンツをぼかす",
|
||||
"blurNsfwContentHelp": "成人向け(NSFW)コンテンツのプレビュー画像をぼかします",
|
||||
"showOnlySfw": "SFWコンテンツのみ表示",
|
||||
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します"
|
||||
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "ホバー時に動画を自動再生",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
|
||||
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
|
||||
"deleteAll": "すべてのモデルを削除",
|
||||
"downloadMissingLoras": "不足している LoRA をダウンロード",
|
||||
"clear": "選択をクリア",
|
||||
"skipMetadataRefreshCount": "スキップ({count}モデル)",
|
||||
"resumeMetadataRefreshCount": "再開({count}モデル)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "ルート",
|
||||
"browseFolders": "フォルダを参照:",
|
||||
"downloadAndSaveRecipe": "ダウンロード & レシピ保存",
|
||||
"importRecipeOnly": "レシピのみインポート",
|
||||
"importAndDownload": "インポートとダウンロード",
|
||||
"downloadMissingLoras": "不足しているLoRAをダウンロード",
|
||||
"saveRecipe": "レシピを保存",
|
||||
"loraCountInfo": "({existing}/{total} ライブラリ内)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "少ない順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "レシピリストを更新"
|
||||
"title": "レシピリストを更新",
|
||||
"quick": "変更を同期",
|
||||
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
|
||||
"full": "キャッシュを再構築",
|
||||
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
|
||||
},
|
||||
"filteredByLora": "LoRAでフィルタ済み",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "レシピの修復に失敗しました: {message}",
|
||||
"missingId": "レシピを修復できません: レシピ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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "ベースモデルを更新",
|
||||
"cancel": "キャンセル"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "不足している LoRA をダウンロード",
|
||||
"message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。",
|
||||
"previewTitle": "ダウンロードする LoRA:",
|
||||
"moreItems": "...あと {count} 個",
|
||||
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
||||
"downloadButton": "{count} 個の LoRA をダウンロード"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "ローカル例画像",
|
||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "{modelType}の読み込みに失敗しました:{message}",
|
||||
"refreshComplete": "更新完了",
|
||||
"refreshFailed": "レシピの更新に失敗しました:{message}",
|
||||
"syncComplete": "同期完了",
|
||||
"syncFailed": "レシピの同期に失敗しました:{message}",
|
||||
"updateFailed": "レシピの更新に失敗しました:{error}",
|
||||
"updateError": "レシピ更新エラー:{message}",
|
||||
"nameSaved": "レシピ\"{name}\"が正常に保存されました",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "処理エラー:{message}",
|
||||
"folderBrowserError": "フォルダブラウザの読み込みエラー:{message}",
|
||||
"recipeSaveFailed": "レシピの保存に失敗しました:{error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "インポートに失敗しました:{message}",
|
||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||
"folderTreeError": "フォルダツリー読み込みエラー"
|
||||
"folderTreeError": "フォルダツリー読み込みエラー",
|
||||
"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}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "モデルが選択されていません",
|
||||
|
||||
105
locales/ko.json
105
locales/ko.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "맨 위로",
|
||||
"settings": "설정",
|
||||
"help": "도움말",
|
||||
"add": "추가"
|
||||
"add": "추가",
|
||||
"close": "닫기"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "프리셋 이름...",
|
||||
"baseModel": "베이스 모델",
|
||||
"modelTags": "태그 (상위 20개)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "모델 유형",
|
||||
"license": "라이선스",
|
||||
"noCreditRequired": "크레딧 표기 없음",
|
||||
"allowSellingGeneratedContent": "판매 허용",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
|
||||
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
|
||||
"showOnlySfw": "SFW 결과만 표시",
|
||||
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다"
|
||||
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "호버 시 비디오 자동 재생",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
|
||||
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
|
||||
"deleteAll": "모든 모델 삭제",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"clear": "선택 지우기",
|
||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "루트",
|
||||
"browseFolders": "폴더 탐색:",
|
||||
"downloadAndSaveRecipe": "다운로드 및 레시피 저장",
|
||||
"importRecipeOnly": "레시피만 가져오기",
|
||||
"importAndDownload": "가져오기 및 다운로드",
|
||||
"downloadMissingLoras": "누락된 LoRA 다운로드",
|
||||
"saveRecipe": "레시피 저장",
|
||||
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "적은순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "레시피 목록 새로고침"
|
||||
"title": "레시피 목록 새로고침",
|
||||
"quick": "변경 사항 동기화",
|
||||
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
|
||||
"full": "캐시 재구성",
|
||||
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
|
||||
},
|
||||
"filteredByLora": "LoRA로 필터링됨",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "레시피 복구 실패: {message}",
|
||||
"missingId": "레시피를 복구할 수 없음: 레시피 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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "베이스 모델 업데이트",
|
||||
"cancel": "취소"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "누락된 LoRA 다운로드",
|
||||
"message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.",
|
||||
"previewTitle": "다운로드할 LoRA:",
|
||||
"moreItems": "...그리고 {count}개 더",
|
||||
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
||||
"downloadButton": "{count}개 LoRA 다운로드"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "로컬 예시 이미지",
|
||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "{modelType} 로딩 실패: {message}",
|
||||
"refreshComplete": "새로고침 완료",
|
||||
"refreshFailed": "레시피 새로고침 실패: {message}",
|
||||
"syncComplete": "동기화 완료",
|
||||
"syncFailed": "레시피 동기화 실패: {message}",
|
||||
"updateFailed": "레시피 업데이트 실패: {error}",
|
||||
"updateError": "레시피 업데이트 오류: {message}",
|
||||
"nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "처리 오류: {message}",
|
||||
"folderBrowserError": "폴더 브라우저 로딩 오류: {message}",
|
||||
"recipeSaveFailed": "레시피 저장 실패: {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "가져오기 실패: {message}",
|
||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||
"folderTreeError": "폴더 트리 로딩 오류"
|
||||
"folderTreeError": "폴더 트리 로딩 오류",
|
||||
"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}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "선택된 모델이 없습니다",
|
||||
|
||||
105
locales/ru.json
105
locales/ru.json
@@ -14,7 +14,8 @@
|
||||
"backToTop": "Наверх",
|
||||
"settings": "Настройки",
|
||||
"help": "Справка",
|
||||
"add": "Добавить"
|
||||
"add": "Добавить",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "Имя пресета...",
|
||||
"baseModel": "Базовая модель",
|
||||
"modelTags": "Теги (Топ 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "Типы моделей",
|
||||
"license": "Лицензия",
|
||||
"noCreditRequired": "Без указания авторства",
|
||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "Размывать NSFW контент",
|
||||
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
|
||||
"showOnlySfw": "Показывать только SFW результаты",
|
||||
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске"
|
||||
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "Автовоспроизведение видео при наведении",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
|
||||
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
|
||||
"deleteAll": "Удалить все модели",
|
||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||
"clear": "Очистить выбор",
|
||||
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
|
||||
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "Корень",
|
||||
"browseFolders": "Обзор папок:",
|
||||
"downloadAndSaveRecipe": "Скачать и сохранить рецепт",
|
||||
"importRecipeOnly": "Импортировать только рецепт",
|
||||
"importAndDownload": "Импорт и скачивание",
|
||||
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
|
||||
"saveRecipe": "Сохранить рецепт",
|
||||
"loraCountInfo": "({existing}/{total} в библиотеке)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "Меньше всего"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список рецептов"
|
||||
"title": "Обновить список рецептов",
|
||||
"quick": "Синхронизировать изменения",
|
||||
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
|
||||
"full": "Перестроить кэш",
|
||||
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
|
||||
},
|
||||
"filteredByLora": "Фильтр по LoRA",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "Не удалось восстановить рецепт: {message}",
|
||||
"missingId": "Не удалось восстановить рецепт: отсутствует 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": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "Обновить базовую модель",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "Скачать отсутствующие LoRAs",
|
||||
"message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).",
|
||||
"previewTitle": "LoRAs для скачивания:",
|
||||
"moreItems": "...и еще {count}",
|
||||
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||
"downloadButton": "Скачать {count} LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Локальные примеры изображений",
|
||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "Не удалось загрузить {modelType}s: {message}",
|
||||
"refreshComplete": "Обновление завершено",
|
||||
"refreshFailed": "Не удалось обновить рецепты: {message}",
|
||||
"syncComplete": "Синхронизация завершена",
|
||||
"syncFailed": "Не удалось синхронизировать рецепты: {message}",
|
||||
"updateFailed": "Не удалось обновить рецепт: {error}",
|
||||
"updateError": "Ошибка обновления рецепта: {message}",
|
||||
"nameSaved": "Рецепт \"{name}\" успешно сохранен",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "Ошибка обработки: {message}",
|
||||
"folderBrowserError": "Ошибка загрузки браузера папок: {message}",
|
||||
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"importFailed": "Импорт не удался: {message}",
|
||||
"folderTreeFailed": "Не удалось загрузить дерево папок",
|
||||
"folderTreeError": "Ошибка загрузки дерева папок"
|
||||
"folderTreeError": "Ошибка загрузки дерева папок",
|
||||
"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}",
|
||||
"noRecipesSelected": "Рецепты не выбраны",
|
||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Модели не выбраны",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "返回顶部",
|
||||
"settings": "设置",
|
||||
"help": "帮助",
|
||||
"add": "添加"
|
||||
"add": "添加",
|
||||
"close": "关闭"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
@@ -162,11 +163,11 @@
|
||||
"error": "清理示例图片文件夹失败:{message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
"label": "刷新许可证元数据",
|
||||
"loading": "正在刷新 {typePlural} 的许可证元数据...",
|
||||
"success": "已更新 {count} 个 {typePlural} 的许可证元数据",
|
||||
"none": "所有 {typePlural} 都已具备许可证元数据",
|
||||
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "修复配方数据",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "预设名称...",
|
||||
"baseModel": "基础模型",
|
||||
"modelTags": "标签(前20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "模型类型",
|
||||
"license": "许可证",
|
||||
"noCreditRequired": "无需署名",
|
||||
"allowSellingGeneratedContent": "允许销售",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "模糊 NSFW 内容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)内容预览图片",
|
||||
"showOnlySfw": "仅显示 SFW 结果",
|
||||
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容"
|
||||
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "悬停时自动播放视频",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
|
||||
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
|
||||
"deleteAll": "删除选中模型",
|
||||
"downloadMissingLoras": "下载缺失的 LoRAs",
|
||||
"clear": "清除选择",
|
||||
"skipMetadataRefreshCount": "跳过({count} 个模型)",
|
||||
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "根目录",
|
||||
"browseFolders": "浏览文件夹:",
|
||||
"downloadAndSaveRecipe": "下载并保存配方",
|
||||
"importRecipeOnly": "仅导入配方",
|
||||
"importAndDownload": "导入并下载",
|
||||
"downloadMissingLoras": "下载缺失的 LoRA",
|
||||
"saveRecipe": "保存配方",
|
||||
"loraCountInfo": "({existing}/{total} in library)",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新配方列表"
|
||||
"title": "刷新配方列表",
|
||||
"quick": "同步变更",
|
||||
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
|
||||
"full": "重建缓存",
|
||||
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
|
||||
},
|
||||
"filteredByLora": "按 LoRA 筛选",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "修复配方失败:{message}",
|
||||
"missingId": "无法修复配方:缺少配方 ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "批量导入配方",
|
||||
"action": "批量导入",
|
||||
"urlList": "URL 列表",
|
||||
"directory": "目录",
|
||||
"urlDescription": "输入图像 URL 或本地文件路径(每行一个)。每个都将作为配方导入。",
|
||||
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
|
||||
"urlsLabel": "图片 URL 或本地路径",
|
||||
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "每行输入一个 URL 或路径",
|
||||
"directoryPath": "目录路径",
|
||||
"directoryPlaceholder": "/图片/文件夹/路径",
|
||||
"browse": "浏览",
|
||||
"recursive": "包含子目录",
|
||||
"tagsOptional": "标签(可选,应用于所有配方)",
|
||||
"tagsPlaceholder": "输入以逗号分隔的标签",
|
||||
"tagsHint": "标签将被添加到所有导入的配方中",
|
||||
"skipNoMetadata": "跳过无元数据的图片",
|
||||
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
|
||||
"start": "开始导入",
|
||||
"startImport": "开始导入",
|
||||
"importing": "正在导入配方...",
|
||||
"progress": "进度",
|
||||
"total": "总计",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"skipped": "跳过",
|
||||
"current": "当前",
|
||||
"currentItem": "当前",
|
||||
"preparing": "准备中...",
|
||||
"cancel": "取消",
|
||||
"cancelImport": "取消",
|
||||
"cancelled": "批量导入已取消",
|
||||
"completed": "导入完成",
|
||||
"completedWithErrors": "导入完成但有错误",
|
||||
"completedSuccess": "成功导入 {count} 个配方",
|
||||
"successCount": "成功",
|
||||
"failedCount": "失败",
|
||||
"skippedCount": "跳过",
|
||||
"totalProcessed": "总计处理",
|
||||
"viewDetails": "查看详情",
|
||||
"newImport": "新建导入",
|
||||
"manualPathEntry": "请手动输入目录路径。此浏览器中文件浏览器不可用。",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"batchImportManualEntryRequired": "文件浏览器不可用。请手动输入目录路径。",
|
||||
"backToParent": "返回上级目录",
|
||||
"folders": "文件夹",
|
||||
"folderCount": "{count} 个文件夹",
|
||||
"imageFiles": "图像文件",
|
||||
"images": "图像",
|
||||
"imageCount": "{count} 个图像",
|
||||
"selectFolder": "选择此文件夹",
|
||||
"errors": {
|
||||
"enterUrls": "请至少输入一个 URL 或路径",
|
||||
"enterDirectory": "请输入目录路径",
|
||||
"startFailed": "启动导入失败:{message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -760,7 +834,7 @@
|
||||
"emptyFolderName": "请输入文件夹名称",
|
||||
"invalidFolderName": "文件夹名称包含无效字符",
|
||||
"noDragState": "未找到待处理的拖放操作"
|
||||
},
|
||||
},
|
||||
"empty": {
|
||||
"noFolders": "未找到文件夹",
|
||||
"dragHint": "拖拽项目到此处以创建文件夹"
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "更新基础模型",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "下载缺失的 LoRAs",
|
||||
"message": "发现 {uniqueCount} 个独特的缺失 LoRAs(从选定配方中的 {totalCount} 个总数)。",
|
||||
"previewTitle": "要下载的 LoRAs:",
|
||||
"moreItems": "...还有 {count} 个",
|
||||
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||
"downloadButton": "下载 {count} 个 LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本地示例图片",
|
||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "加载 {modelType} 失败:{message}",
|
||||
"refreshComplete": "刷新完成",
|
||||
"refreshFailed": "刷新配方失败:{message}",
|
||||
"syncComplete": "同步完成",
|
||||
"syncFailed": "同步配方失败:{message}",
|
||||
"updateFailed": "更新配方失败:{error}",
|
||||
"updateError": "更新配方出错:{message}",
|
||||
"nameSaved": "配方“{name}”保存成功",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "处理出错:{message}",
|
||||
"folderBrowserError": "加载文件夹浏览器出错:{message}",
|
||||
"recipeSaveFailed": "保存配方失败:{error}",
|
||||
"recipeSaved": "配方保存成功",
|
||||
"importFailed": "导入失败:{message}",
|
||||
"folderTreeFailed": "加载文件夹树失败",
|
||||
"folderTreeError": "加载文件夹树出错"
|
||||
"folderTreeError": "加载文件夹树出错",
|
||||
"batchImportFailed": "启动批量导入失败:{message}",
|
||||
"batchImportCancelling": "正在取消批量导入...",
|
||||
"batchImportCancelFailed": "取消批量导入失败:{message}",
|
||||
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
|
||||
"batchImportNoDirectory": "请输入目录路径",
|
||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"noRecipesSelected": "未选择任何配方",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未选中模型",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"backToTop": "回到頂部",
|
||||
"settings": "設定",
|
||||
"help": "說明",
|
||||
"add": "新增"
|
||||
"add": "新增",
|
||||
"close": "關閉"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
@@ -222,7 +223,7 @@
|
||||
"presetNamePlaceholder": "預設名稱...",
|
||||
"baseModel": "基礎模型",
|
||||
"modelTags": "標籤(前 20)",
|
||||
"modelTypes": "Model Types",
|
||||
"modelTypes": "模型類型",
|
||||
"license": "授權",
|
||||
"noCreditRequired": "無需署名",
|
||||
"allowSellingGeneratedContent": "允許銷售",
|
||||
@@ -290,7 +291,15 @@
|
||||
"blurNsfwContent": "模糊 NSFW 內容",
|
||||
"blurNsfwContentHelp": "模糊成熟(NSFW)內容預覽圖片",
|
||||
"showOnlySfw": "僅顯示 SFW 結果",
|
||||
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容"
|
||||
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
|
||||
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
|
||||
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
|
||||
"matureBlurThresholdOptions": {
|
||||
"pg13": "[TODO: Translate] PG13 and above",
|
||||
"r": "[TODO: Translate] R and above (default)",
|
||||
"x": "[TODO: Translate] X and above",
|
||||
"xxx": "[TODO: Translate] XXX only"
|
||||
}
|
||||
},
|
||||
"videoSettings": {
|
||||
"autoplayOnHover": "滑鼠懸停自動播放影片",
|
||||
@@ -574,6 +583,7 @@
|
||||
"skipMetadataRefresh": "跳過所選模型的元數據更新",
|
||||
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
|
||||
"deleteAll": "刪除全部模型",
|
||||
"downloadMissingLoras": "下載缺失的 LoRAs",
|
||||
"clear": "清除選取",
|
||||
"skipMetadataRefreshCount": "跳過({count} 個模型)",
|
||||
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
|
||||
@@ -644,6 +654,8 @@
|
||||
"root": "根目錄",
|
||||
"browseFolders": "瀏覽資料夾:",
|
||||
"downloadAndSaveRecipe": "下載並儲存配方",
|
||||
"importRecipeOnly": "僅匯入配方",
|
||||
"importAndDownload": "匯入並下載",
|
||||
"downloadMissingLoras": "下載缺少的 LoRA",
|
||||
"saveRecipe": "儲存配方",
|
||||
"loraCountInfo": "(庫存 {existing}/{total})",
|
||||
@@ -685,7 +697,11 @@
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理配方列表"
|
||||
"title": "重新整理配方列表",
|
||||
"quick": "同步變更",
|
||||
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
|
||||
"full": "重建快取",
|
||||
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
|
||||
},
|
||||
"filteredByLora": "已依 LoRA 篩選",
|
||||
"favorites": {
|
||||
@@ -725,6 +741,64 @@
|
||||
"failed": "修復配方失敗:{message}",
|
||||
"missingId": "無法修復配方:缺少配方 ID"
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "批量匯入配方",
|
||||
"action": "批量匯入",
|
||||
"urlList": "URL 列表",
|
||||
"directory": "目錄",
|
||||
"urlDescription": "輸入圖像 URL 或本地檔案路徑(每行一個)。每個都將作為配方匯入。",
|
||||
"directoryDescription": "輸入目錄路徑以匯入該資料夾中的所有圖像。",
|
||||
"urlsLabel": "圖像 URL 或本地路徑",
|
||||
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "每行輸入一個 URL 或路徑",
|
||||
"directoryPath": "目錄路徑",
|
||||
"directoryPlaceholder": "/path/to/images/folder",
|
||||
"browse": "瀏覽",
|
||||
"recursive": "包含子目錄",
|
||||
"tagsOptional": "標籤(可選,應用於所有配方)",
|
||||
"tagsPlaceholder": "輸入以逗號分隔的標籤",
|
||||
"tagsHint": "標籤將被添加到所有匯入的配方中",
|
||||
"skipNoMetadata": "跳過無元資料的圖像",
|
||||
"skipNoMetadataHelp": "沒有 LoRA 元資料的圖像將被自動跳過。",
|
||||
"start": "開始匯入",
|
||||
"startImport": "開始匯入",
|
||||
"importing": "匯入中...",
|
||||
"progress": "進度",
|
||||
"total": "總計",
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"skipped": "跳過",
|
||||
"current": "當前",
|
||||
"currentItem": "當前項目",
|
||||
"preparing": "準備中...",
|
||||
"cancel": "取消",
|
||||
"cancelImport": "取消匯入",
|
||||
"cancelled": "匯入已取消",
|
||||
"completed": "匯入完成",
|
||||
"completedWithErrors": "匯入完成但有錯誤",
|
||||
"completedSuccess": "成功匯入 {count} 個配方",
|
||||
"successCount": "成功",
|
||||
"failedCount": "失敗",
|
||||
"skippedCount": "跳過",
|
||||
"totalProcessed": "總計處理",
|
||||
"viewDetails": "查看詳情",
|
||||
"newImport": "新建匯入",
|
||||
"manualPathEntry": "請手動輸入目錄路徑。此瀏覽器中檔案瀏覽器不可用。",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"batchImportManualEntryRequired": "檔案瀏覽器不可用。請手動輸入目錄路徑。",
|
||||
"backToParent": "返回上級目錄",
|
||||
"folders": "資料夾",
|
||||
"folderCount": "{count} 個資料夾",
|
||||
"imageFiles": "圖像檔案",
|
||||
"images": "圖像",
|
||||
"imageCount": "{count} 個圖像",
|
||||
"selectFolder": "選擇此資料夾",
|
||||
"errors": {
|
||||
"enterUrls": "請輸入至少一個 URL 或路徑",
|
||||
"enterDirectory": "請輸入目錄路徑",
|
||||
"startFailed": "啟動匯入失敗:{message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"checkpoints": {
|
||||
@@ -918,6 +992,14 @@
|
||||
"save": "更新基礎模型",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "下載缺失的 LoRAs",
|
||||
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取食譜中的 {totalCount} 個總數)。",
|
||||
"previewTitle": "要下載的 LoRAs:",
|
||||
"moreItems": "...還有 {count} 個",
|
||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||
"downloadButton": "下載 {count} 個 LoRA(s)"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本機範例圖片",
|
||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||
@@ -1396,6 +1478,8 @@
|
||||
"loadFailed": "載入 {modelType} 失敗:{message}",
|
||||
"refreshComplete": "刷新完成",
|
||||
"refreshFailed": "刷新配方失敗:{message}",
|
||||
"syncComplete": "同步完成",
|
||||
"syncFailed": "同步配方失敗:{message}",
|
||||
"updateFailed": "更新配方失敗:{error}",
|
||||
"updateError": "更新配方錯誤:{message}",
|
||||
"nameSaved": "配方「{name}」已成功儲存",
|
||||
@@ -1430,9 +1514,20 @@
|
||||
"processingError": "處理錯誤:{message}",
|
||||
"folderBrowserError": "載入資料夾瀏覽器錯誤:{message}",
|
||||
"recipeSaveFailed": "儲存配方失敗:{error}",
|
||||
"recipeSaved": "配方儲存成功",
|
||||
"importFailed": "匯入失敗:{message}",
|
||||
"folderTreeFailed": "載入資料夾樹狀結構失敗",
|
||||
"folderTreeError": "載入資料夾樹狀結構錯誤"
|
||||
"folderTreeError": "載入資料夾樹狀結構錯誤",
|
||||
"batchImportFailed": "啟動批量匯入失敗:{message}",
|
||||
"batchImportCancelling": "正在取消批量匯入...",
|
||||
"batchImportCancelFailed": "取消批量匯入失敗:{message}",
|
||||
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
|
||||
"batchImportNoDirectory": "請輸入目錄路徑",
|
||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"noRecipesSelected": "未選取任何食譜",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未選擇模型",
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -114,7 +114,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -138,7 +137,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1613,7 +1611,6 @@
|
||||
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.0.1",
|
||||
"data-urls": "^5.0.0",
|
||||
|
||||
47
py/config.py
47
py/config.py
@@ -707,7 +707,13 @@ class Config:
|
||||
|
||||
def _prepare_checkpoint_paths(
|
||||
self, checkpoint_paths: Iterable[str], unet_paths: Iterable[str]
|
||||
) -> List[str]:
|
||||
) -> Tuple[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)
|
||||
unet_map = self._dedupe_existing_paths(unet_paths)
|
||||
|
||||
@@ -737,8 +743,8 @@ class Config:
|
||||
|
||||
checkpoint_values = set(checkpoint_map.values())
|
||||
unet_values = set(unet_map.values())
|
||||
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_values]
|
||||
self.unet_roots = [p for p in unique_paths if p in unet_values]
|
||||
checkpoint_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]
|
||||
|
||||
for original_path in unique_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(original_path)).replace(
|
||||
@@ -747,7 +753,7 @@ class Config:
|
||||
if real_path != original_path:
|
||||
self.add_path_mapping(original_path, real_path)
|
||||
|
||||
return unique_paths
|
||||
return unique_paths, checkpoint_roots, unet_roots
|
||||
|
||||
def _prepare_embedding_paths(self, raw_paths: Iterable[str]) -> List[str]:
|
||||
path_map = self._dedupe_existing_paths(raw_paths)
|
||||
@@ -776,9 +782,11 @@ class Config:
|
||||
embedding_paths = folder_paths.get("embeddings", []) or []
|
||||
|
||||
self.loras_roots = self._prepare_lora_paths(lora_paths)
|
||||
self.base_models_roots = self._prepare_checkpoint_paths(
|
||||
checkpoint_paths, unet_paths
|
||||
)
|
||||
(
|
||||
self.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)
|
||||
|
||||
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||
@@ -789,18 +797,11 @@ class Config:
|
||||
extra_embedding_paths = extra_paths.get("embeddings", []) or []
|
||||
|
||||
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
|
||||
# Save main paths before processing extra paths ( _prepare_checkpoint_paths overwrites them)
|
||||
saved_checkpoints_roots = self.checkpoints_roots
|
||||
saved_unet_roots = self.unet_roots
|
||||
self.extra_checkpoints_roots = self._prepare_checkpoint_paths(
|
||||
extra_checkpoint_paths, extra_unet_paths
|
||||
)
|
||||
self.extra_unet_roots = (
|
||||
self.unet_roots if self.unet_roots is not None else []
|
||||
) # unet_roots was set by _prepare_checkpoint_paths
|
||||
# Restore main paths
|
||||
self.checkpoints_roots = saved_checkpoints_roots
|
||||
self.unet_roots = saved_unet_roots
|
||||
(
|
||||
_,
|
||||
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
|
||||
)
|
||||
@@ -857,9 +858,11 @@ class Config:
|
||||
try:
|
||||
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||
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(
|
||||
"Found checkpoint roots:"
|
||||
|
||||
@@ -148,10 +148,13 @@ class MetadataHook:
|
||||
"""Install hooks for asynchronous execution model"""
|
||||
# Store the original _async_map_node_over_list function
|
||||
original_map_node_over_list = getattr(execution, map_node_func_name)
|
||||
|
||||
# Wrapped async function, compatible with both stable and nightly
|
||||
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):
|
||||
hidden_inputs = kwargs.get('hidden_inputs', None)
|
||||
|
||||
# Wrapped async function - signature must exactly match _async_map_node_over_list
|
||||
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, v3_data=None
|
||||
):
|
||||
# Only collect metadata when calling the main function of nodes
|
||||
if func == obj.FUNCTION and hasattr(obj, '__class__'):
|
||||
try:
|
||||
@@ -163,13 +166,13 @@ class MetadataHook:
|
||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||
|
||||
# Call original function with all args/kwargs
|
||||
|
||||
# Call original function with exact parameters
|
||||
results = await original_map_node_over_list(
|
||||
prompt_id, unique_id, obj, input_data_all, func,
|
||||
allow_interrupt, execution_block_cb, pre_execute_cb, *args, **kwargs
|
||||
allow_interrupt, execution_block_cb, pre_execute_cb, v3_data=v3_data
|
||||
)
|
||||
|
||||
|
||||
if func == obj.FUNCTION and hasattr(obj, '__class__'):
|
||||
try:
|
||||
registry = MetadataRegistry()
|
||||
@@ -180,28 +183,28 @@ class MetadataHook:
|
||||
registry.update_node_execution(node_id, class_type, results)
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting metadata (post-execution): {str(e)}")
|
||||
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Also hook the execute function to track the current prompt_id
|
||||
original_execute = execution.execute
|
||||
|
||||
|
||||
async def async_execute_with_prompt_tracking(*args, **kwargs):
|
||||
if len(args) >= 7: # Check if we have enough arguments
|
||||
server, prompt, caches, node_id, extra_data, executed, prompt_id = args[:7]
|
||||
registry = MetadataRegistry()
|
||||
|
||||
|
||||
# Start collection if this is a new prompt
|
||||
if not registry.current_prompt_id or registry.current_prompt_id != prompt_id:
|
||||
registry.start_collection(prompt_id)
|
||||
|
||||
|
||||
# Store the dynprompt reference for node lookups
|
||||
if hasattr(prompt, 'original_prompt'):
|
||||
registry.set_current_prompt(prompt)
|
||||
|
||||
|
||||
# Execute the original function
|
||||
return await original_execute(*args, **kwargs)
|
||||
|
||||
|
||||
# Replace the functions with async versions
|
||||
setattr(execution, map_node_func_name, async_map_node_over_list_with_metadata)
|
||||
execution.execute = async_execute_with_prompt_tracking
|
||||
|
||||
118
py/nodes/checkpoint_loader.py
Normal file
118
py/nodes/checkpoint_loader.py
Normal file
@@ -0,0 +1,118 @@
|
||||
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]
|
||||
161
py/nodes/gguf_import_helper.py
Normal file
161
py/nodes/gguf_import_helper.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
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")
|
||||
@@ -56,6 +56,9 @@ class LoraCyclerLM:
|
||||
clip_strength = float(cycler_config.get("clip_strength", 1.0))
|
||||
sort_by = "filename"
|
||||
|
||||
# Include "no lora" option
|
||||
include_no_lora = cycler_config.get("include_no_lora", False)
|
||||
|
||||
# Dual-index mechanism for batch queue synchronization
|
||||
execution_index = cycler_config.get("execution_index") # Can be None
|
||||
# next_index_from_config = cycler_config.get("next_index") # Not used on backend
|
||||
@@ -71,7 +74,10 @@ class LoraCyclerLM:
|
||||
|
||||
total_count = len(lora_list)
|
||||
|
||||
if total_count == 0:
|
||||
# Calculate effective total count (includes no lora option if enabled)
|
||||
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")
|
||||
return {
|
||||
"result": ([],),
|
||||
@@ -93,42 +99,66 @@ class LoraCyclerLM:
|
||||
else:
|
||||
actual_index = current_index
|
||||
|
||||
# Clamp index to valid range (1-based)
|
||||
clamped_index = max(1, min(actual_index, total_count))
|
||||
# Clamp index to valid range (1-based, includes no lora if enabled)
|
||||
clamped_index = max(1, min(actual_index, effective_total_count))
|
||||
|
||||
# Get LoRA at current index (convert to 0-based for list access)
|
||||
current_lora = lora_list[clamped_index - 1]
|
||||
# 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
|
||||
|
||||
# Build LORA_STACK with single LoRA
|
||||
lora_path, _ = get_lora_info(current_lora["file_name"])
|
||||
if not lora_path:
|
||||
logger.warning(
|
||||
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
||||
)
|
||||
if is_no_lora:
|
||||
# "No LoRA" option - return empty stack
|
||||
lora_stack = []
|
||||
current_lora_name = "No LoRA"
|
||||
current_lora_filename = "No LoRA"
|
||||
else:
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
# Get LoRA at current index (convert to 0-based for list access)
|
||||
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
|
||||
if current_lora["file_name"] == "None":
|
||||
lora_path = None
|
||||
else:
|
||||
lora_path, _ = get_lora_info(current_lora["file_name"])
|
||||
|
||||
if not lora_path:
|
||||
if current_lora["file_name"] != "None":
|
||||
logger.warning(
|
||||
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
||||
)
|
||||
lora_stack = []
|
||||
else:
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
|
||||
# Calculate next index (wrap to 1 if at end)
|
||||
next_index = clamped_index + 1
|
||||
if next_index > total_count:
|
||||
if next_index > effective_total_count:
|
||||
next_index = 1
|
||||
|
||||
# Get next LoRA for UI display (what will be used next generation)
|
||||
next_lora = lora_list[next_index - 1]
|
||||
next_display_name = next_lora["file_name"]
|
||||
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_display_name = next_lora["file_name"]
|
||||
next_lora_filename = next_lora["file_name"]
|
||||
|
||||
return {
|
||||
"result": (lora_stack,),
|
||||
"ui": {
|
||||
"current_index": [clamped_index],
|
||||
"next_index": [next_index],
|
||||
"total_count": [total_count],
|
||||
"current_lora_name": [current_lora["file_name"]],
|
||||
"current_lora_filename": [current_lora["file_name"]],
|
||||
"total_count": [
|
||||
total_count
|
||||
], # Return actual LoRA count, not effective_total_count
|
||||
"current_lora_name": [current_lora_name],
|
||||
"current_lora_filename": [current_lora_filename],
|
||||
"next_lora_name": [next_display_name],
|
||||
"next_lora_filename": [next_lora["file_name"]],
|
||||
"next_lora_filename": [next_lora_filename],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class LoraPoolLM:
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"favoritesOnly": False,
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
|
||||
},
|
||||
"preview": {"matchCount": 0, "lastUpdated": 0},
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ and tracks the last used combination for reuse.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import extract_lora_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
205
py/nodes/unet_loader.py
Normal file
205
py/nodes/unet_loader.py
Normal file
@@ -0,0 +1,205 @@
|
||||
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)}"
|
||||
)
|
||||
@@ -7,6 +7,7 @@ from .parsers import (
|
||||
MetaFormatParser,
|
||||
AutomaticMetadataParser,
|
||||
CivitaiApiMetadataParser,
|
||||
SuiImageParamsParser,
|
||||
)
|
||||
from .base import RecipeMetadataParser
|
||||
|
||||
@@ -55,6 +56,13 @@ class RecipeParserFactory:
|
||||
# If JSON parsing fails, move on to other parsers
|
||||
pass
|
||||
|
||||
# Try SuiImageParamsParser for SuiImage metadata format
|
||||
try:
|
||||
if SuiImageParamsParser().is_metadata_matching(metadata_str):
|
||||
return SuiImageParamsParser()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check other parsers that expect string input
|
||||
if RecipeFormatParser().is_metadata_matching(metadata_str):
|
||||
return RecipeFormatParser()
|
||||
|
||||
@@ -5,6 +5,7 @@ from .comfy import ComfyMetadataParser
|
||||
from .meta_format import MetaFormatParser
|
||||
from .automatic import AutomaticMetadataParser
|
||||
from .civitai_image import CivitaiApiMetadataParser
|
||||
from .sui_image_params import SuiImageParamsParser
|
||||
|
||||
__all__ = [
|
||||
'RecipeFormatParser',
|
||||
@@ -12,4 +13,5 @@ __all__ = [
|
||||
'MetaFormatParser',
|
||||
'AutomaticMetadataParser',
|
||||
'CivitaiApiMetadataParser',
|
||||
'SuiImageParamsParser',
|
||||
]
|
||||
|
||||
188
py/recipes/parsers/sui_image_params.py
Normal file
188
py/recipes/parsers/sui_image_params.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Parser for SuiImage (Stable Diffusion WebUI) metadata format."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
from ..base import RecipeMetadataParser
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuiImageParamsParser(RecipeMetadataParser):
|
||||
"""Parser for SuiImage metadata JSON format.
|
||||
|
||||
This format is used by some Stable Diffusion WebUI variants.
|
||||
Structure:
|
||||
{
|
||||
"sui_image_params": {
|
||||
"prompt": "...",
|
||||
"negativeprompt": "...",
|
||||
"model": "...",
|
||||
"seed": ...,
|
||||
"steps": ...,
|
||||
...
|
||||
},
|
||||
"sui_models": [
|
||||
{"name": "...", "param": "model", "hash": "..."},
|
||||
...
|
||||
],
|
||||
"sui_extra_data": {...}
|
||||
}
|
||||
"""
|
||||
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
"""Check if the user comment matches the SuiImage metadata format"""
|
||||
try:
|
||||
data = json.loads(user_comment)
|
||||
return isinstance(data, dict) and 'sui_image_params' in data
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return False
|
||||
|
||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||
"""Parse metadata from SuiImage metadata format"""
|
||||
try:
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
|
||||
data = json.loads(user_comment)
|
||||
params = data.get('sui_image_params', {})
|
||||
models = data.get('sui_models', [])
|
||||
|
||||
# Extract prompt and negative prompt
|
||||
prompt = params.get('prompt', '')
|
||||
negative_prompt = params.get('negativeprompt', '') or params.get('negative_prompt', '')
|
||||
|
||||
# Extract generation parameters
|
||||
gen_params = {}
|
||||
if prompt:
|
||||
gen_params['prompt'] = prompt
|
||||
if negative_prompt:
|
||||
gen_params['negative_prompt'] = negative_prompt
|
||||
|
||||
# Map standard parameters
|
||||
param_mapping = {
|
||||
'steps': 'steps',
|
||||
'seed': 'seed',
|
||||
'cfgscale': 'cfg_scale',
|
||||
'cfg_scale': 'cfg_scale',
|
||||
'width': 'width',
|
||||
'height': 'height',
|
||||
'sampler': 'sampler',
|
||||
'scheduler': 'scheduler',
|
||||
'model': 'model',
|
||||
'vae': 'vae',
|
||||
}
|
||||
|
||||
for src_key, dest_key in param_mapping.items():
|
||||
if src_key in params and params[src_key] is not None:
|
||||
gen_params[dest_key] = params[src_key]
|
||||
|
||||
# Add size info if available
|
||||
if 'width' in gen_params and 'height' in gen_params:
|
||||
gen_params['size'] = f"{gen_params['width']}x{gen_params['height']}"
|
||||
|
||||
# Process models - extract checkpoint and loras
|
||||
loras: List[Dict[str, Any]] = []
|
||||
checkpoint: Optional[Dict[str, Any]] = None
|
||||
|
||||
for model in models:
|
||||
model_name = model.get('name', '')
|
||||
param_type = model.get('param', '')
|
||||
model_hash = model.get('hash', '')
|
||||
|
||||
# Remove .safetensors extension for cleaner name
|
||||
clean_name = model_name.replace('.safetensors', '') if model_name else ''
|
||||
|
||||
# Check if this is a LoRA by looking at the name or param type
|
||||
is_lora = 'lora' in model_name.lower() or param_type.lower().startswith('lora')
|
||||
|
||||
if is_lora:
|
||||
lora_entry = {
|
||||
'id': 0,
|
||||
'modelId': 0,
|
||||
'name': clean_name,
|
||||
'version': '',
|
||||
'type': 'lora',
|
||||
'weight': 1.0,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': model_name,
|
||||
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Try to get additional info from metadata provider
|
||||
if metadata_provider and model_hash:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(
|
||||
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
|
||||
)
|
||||
if civitai_info:
|
||||
lora_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry, civitai_info, recipe_scanner
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching info for LoRA {clean_name}: {e}")
|
||||
|
||||
if lora_entry:
|
||||
loras.append(lora_entry)
|
||||
elif param_type == 'model' or 'lora' not in model_name.lower():
|
||||
# This is likely a checkpoint
|
||||
checkpoint_entry = {
|
||||
'id': 0,
|
||||
'modelId': 0,
|
||||
'name': clean_name,
|
||||
'version': '',
|
||||
'type': 'checkpoint',
|
||||
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': model_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Try to get additional info from metadata provider
|
||||
if metadata_provider and model_hash:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(
|
||||
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
|
||||
)
|
||||
if civitai_info:
|
||||
checkpoint_entry = await self.populate_checkpoint_from_civitai(
|
||||
checkpoint_entry, civitai_info
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching info for checkpoint {clean_name}: {e}")
|
||||
|
||||
checkpoint = checkpoint_entry
|
||||
|
||||
# Determine base model from loras or checkpoint
|
||||
base_model = None
|
||||
if loras:
|
||||
base_models = [lora.get('baseModel') for lora in loras if lora.get('baseModel')]
|
||||
if base_models:
|
||||
from collections import Counter
|
||||
base_model_counts = Counter(base_models)
|
||||
base_model = base_model_counts.most_common(1)[0][0]
|
||||
elif checkpoint and checkpoint.get('baseModel'):
|
||||
base_model = checkpoint['baseModel']
|
||||
|
||||
return {
|
||||
'base_model': base_model,
|
||||
'loras': loras,
|
||||
'checkpoint': checkpoint,
|
||||
'gen_params': gen_params,
|
||||
'from_sui_image_params': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing SuiImage metadata: {e}", exc_info=True)
|
||||
return {"error": str(e), "loras": []}
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Base infrastructure shared across recipe routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -16,12 +17,14 @@ from ..services.recipes import (
|
||||
RecipePersistenceService,
|
||||
RecipeSharingService,
|
||||
)
|
||||
from ..services.batch_import_service import BatchImportService
|
||||
from ..services.server_i18n import server_i18n
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from .handlers.recipe_handlers import (
|
||||
BatchImportHandler,
|
||||
RecipeAnalysisHandler,
|
||||
RecipeHandlerSet,
|
||||
RecipeListingHandler,
|
||||
@@ -116,7 +119,10 @@ class BaseRecipeRoutes:
|
||||
recipe_scanner_getter = lambda: self.recipe_scanner
|
||||
civitai_client_getter = lambda: self.civitai_client
|
||||
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
standalone_mode = (
|
||||
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
)
|
||||
if not standalone_mode:
|
||||
from ..metadata_collector import get_metadata # type: ignore[import-not-found]
|
||||
from ..metadata_collector.metadata_processor import ( # type: ignore[import-not-found]
|
||||
@@ -190,6 +196,22 @@ class BaseRecipeRoutes:
|
||||
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(
|
||||
page_view=page_view,
|
||||
listing=listing,
|
||||
@@ -197,4 +219,5 @@ class BaseRecipeRoutes:
|
||||
management=management,
|
||||
analysis=analysis,
|
||||
sharing=sharing,
|
||||
batch_import=batch_import,
|
||||
)
|
||||
|
||||
@@ -240,11 +240,7 @@ class SupportersHandler:
|
||||
except Exception as e:
|
||||
self._logger.debug(f"Failed to load supporters data: {e}")
|
||||
|
||||
return {
|
||||
"specialThanks": [],
|
||||
"allSupporters": [],
|
||||
"totalCount": 0
|
||||
}
|
||||
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
|
||||
|
||||
async def get_supporters(self, request: web.Request) -> web.Response:
|
||||
"""Return supporters data as JSON."""
|
||||
@@ -253,9 +249,101 @@ class SupportersHandler:
|
||||
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
|
||||
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:
|
||||
@@ -263,15 +351,17 @@ class SettingsHandler:
|
||||
|
||||
# Settings keys that should NOT be synced to frontend.
|
||||
# All other settings are synced by default.
|
||||
_NO_SYNC_KEYS = frozenset({
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
})
|
||||
_NO_SYNC_KEYS = frozenset(
|
||||
{
|
||||
# Internal/performance settings (not used by frontend)
|
||||
"hash_chunk_size_mb",
|
||||
"download_stall_timeout_seconds",
|
||||
# Complex internal structures retrieved via separate endpoints
|
||||
"folder_paths",
|
||||
"libraries",
|
||||
"active_library",
|
||||
}
|
||||
)
|
||||
|
||||
_PROXY_KEYS = {
|
||||
"proxy_enabled",
|
||||
@@ -1226,6 +1316,7 @@ class CustomWordsHandler:
|
||||
|
||||
def __init__(self) -> None:
|
||||
from ...services.custom_words_service import get_custom_words_service
|
||||
|
||||
self._service = get_custom_words_service()
|
||||
|
||||
async def search_custom_words(self, request: web.Request) -> web.Response:
|
||||
@@ -1234,6 +1325,7 @@ class CustomWordsHandler:
|
||||
Query parameters:
|
||||
search: The search term to match against.
|
||||
limit: Maximum number of results to return (default: 20).
|
||||
offset: Number of results to skip (default: 0).
|
||||
category: Optional category filter. Can be:
|
||||
- A category name (e.g., "character", "artist", "general")
|
||||
- Comma-separated category IDs (e.g., "4,11" for character)
|
||||
@@ -1243,6 +1335,7 @@ class CustomWordsHandler:
|
||||
try:
|
||||
search_term = request.query.get("search", "")
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
category_param = request.query.get("category", "")
|
||||
enriched_param = request.query.get("enriched", "").lower() == "true"
|
||||
|
||||
@@ -1252,13 +1345,14 @@ class CustomWordsHandler:
|
||||
categories = self._parse_category_param(category_param)
|
||||
|
||||
results = self._service.search_words(
|
||||
search_term, limit, categories=categories, enriched=enriched_param
|
||||
search_term,
|
||||
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:
|
||||
logger.error("Error searching custom words: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
@@ -1523,6 +1617,7 @@ class MiscHandlerSet:
|
||||
filesystem: FileSystemHandler,
|
||||
custom_words: CustomWordsHandler,
|
||||
supporters: SupportersHandler,
|
||||
example_workflows: ExampleWorkflowsHandler,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -1536,6 +1631,7 @@ class MiscHandlerSet:
|
||||
self.filesystem = filesystem
|
||||
self.custom_words = custom_words
|
||||
self.supporters = supporters
|
||||
self.example_workflows = example_workflows
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -1565,6 +1661,8 @@ class MiscHandlerSet:
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -309,6 +309,13 @@ class ModelListingHandler:
|
||||
else:
|
||||
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 {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -328,6 +335,9 @@ class ModelListingHandler:
|
||||
"credit_required": credit_required,
|
||||
"allow_selling_generated_content": allow_selling_generated_content,
|
||||
"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),
|
||||
}
|
||||
|
||||
@@ -1268,8 +1278,11 @@ class ModelQueryHandler:
|
||||
async def get_relative_paths(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
search = request.query.get("search", "").strip()
|
||||
limit = min(int(request.query.get("limit", "15")), 50)
|
||||
matching_paths = await self._service.search_relative_paths(search, limit)
|
||||
limit = min(int(request.query.get("limit", "15")), 100)
|
||||
offset = max(0, int(request.query.get("offset", "0")))
|
||||
matching_paths = await self._service.search_relative_paths(
|
||||
search, limit, offset
|
||||
)
|
||||
return web.json_response(
|
||||
{"success": True, "relative_paths": matching_paths}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Dedicated handler objects for recipe-related routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -8,6 +9,7 @@ import re
|
||||
import asyncio
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||
|
||||
from aiohttp import web
|
||||
@@ -29,6 +31,7 @@ from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
from ...recipes.enrichment import RecipeEnricher
|
||||
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||
from ...services.batch_import_service import BatchImportService
|
||||
|
||||
Logger = logging.Logger
|
||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||
@@ -46,8 +49,11 @@ class RecipeHandlerSet:
|
||||
management: "RecipeManagementHandler"
|
||||
analysis: "RecipeAnalysisHandler"
|
||||
sharing: "RecipeSharingHandler"
|
||||
batch_import: "BatchImportHandler"
|
||||
|
||||
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
def to_route_mapping(
|
||||
self,
|
||||
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
"""Expose handler coroutines keyed by registrar handler names."""
|
||||
|
||||
return {
|
||||
@@ -81,6 +87,11 @@ class RecipeHandlerSet:
|
||||
"cancel_repair": self.management.cancel_repair,
|
||||
"repair_recipe": self.management.repair_recipe,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -170,8 +181,10 @@ class RecipeListingHandler:
|
||||
search_options = {
|
||||
"title": request.query.get("search_title", "true").lower() == "true",
|
||||
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
||||
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||
"lora_name": request.query.get("search_lora_name", "true").lower()
|
||||
== "true",
|
||||
"lora_model": request.query.get("search_lora_model", "true").lower()
|
||||
== "true",
|
||||
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||
}
|
||||
|
||||
@@ -246,7 +259,9 @@ class RecipeListingHandler:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
return web.json_response(recipe)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipe details: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error retrieving recipe details: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
def format_recipe_file_url(self, file_path: str) -> str:
|
||||
@@ -256,7 +271,9 @@ class RecipeListingHandler:
|
||||
if static_url:
|
||||
return static_url
|
||||
except Exception as exc: # pragma: no cover - logging path
|
||||
self._logger.error("Error formatting recipe file URL: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error formatting recipe file URL: %s", exc, exc_info=True
|
||||
)
|
||||
return "/loras_static/images/no-preview.png"
|
||||
|
||||
return "/loras_static/images/no-preview.png"
|
||||
@@ -293,7 +310,9 @@ class RecipeQueryHandler:
|
||||
for tag in recipe.get("tags", []) or []:
|
||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||
|
||||
sorted_tags = [{"tag": tag, "count": count} for tag, count in tag_counts.items()]
|
||||
sorted_tags = [
|
||||
{"tag": tag, "count": count} for tag, count in tag_counts.items()
|
||||
]
|
||||
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
return web.json_response({"success": True, "tags": sorted_tags[:limit]})
|
||||
except Exception as exc:
|
||||
@@ -313,9 +332,14 @@ class RecipeQueryHandler:
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
base_model = recipe.get("base_model")
|
||||
if base_model:
|
||||
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||
base_model_counts[base_model] = (
|
||||
base_model_counts.get(base_model, 0) + 1
|
||||
)
|
||||
|
||||
sorted_models = [{"name": model, "count": count} for model, count in base_model_counts.items()]
|
||||
sorted_models = [
|
||||
{"name": model, "count": count}
|
||||
for model, count in base_model_counts.items()
|
||||
]
|
||||
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
return web.json_response({"success": True, "base_models": sorted_models})
|
||||
except Exception as exc:
|
||||
@@ -345,7 +369,9 @@ class RecipeQueryHandler:
|
||||
folders = await recipe_scanner.get_folders()
|
||||
return web.json_response({"success": True, "folders": folders})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error retrieving recipe folders: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
||||
@@ -358,7 +384,9 @@ class RecipeQueryHandler:
|
||||
folder_tree = await recipe_scanner.get_folder_tree()
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error retrieving recipe folder tree: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||
@@ -371,7 +399,9 @@ class RecipeQueryHandler:
|
||||
folder_tree = await recipe_scanner.get_folder_tree()
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error retrieving unified recipe folder tree: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||
@@ -383,7 +413,9 @@ class RecipeQueryHandler:
|
||||
|
||||
lora_hash = request.query.get("hash")
|
||||
if not lora_hash:
|
||||
return web.json_response({"success": False, "error": "Lora hash is required"}, status=400)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Lora hash is required"}, status=400
|
||||
)
|
||||
|
||||
matching_recipes = await recipe_scanner.get_recipes_for_lora(lora_hash)
|
||||
return web.json_response({"success": True, "recipes": matching_recipes})
|
||||
@@ -400,7 +432,9 @@ class RecipeQueryHandler:
|
||||
|
||||
self._logger.info("Manually triggering recipe cache rebuild")
|
||||
await recipe_scanner.get_cached_data(force_refresh=True)
|
||||
return web.json_response({"success": True, "message": "Recipe cache refreshed successfully"})
|
||||
return web.json_response(
|
||||
{"success": True, "message": "Recipe cache refreshed successfully"}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error refreshing recipe cache: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -429,7 +463,9 @@ class RecipeQueryHandler:
|
||||
"id": recipe.get("id"),
|
||||
"title": recipe.get("title"),
|
||||
"file_url": recipe.get("file_url")
|
||||
or self._format_recipe_file_url(recipe.get("file_path", "")),
|
||||
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", [])),
|
||||
@@ -437,7 +473,9 @@ class RecipeQueryHandler:
|
||||
)
|
||||
|
||||
if len(recipes) >= 2:
|
||||
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
|
||||
recipes.sort(
|
||||
key=lambda entry: entry.get("modified", 0), reverse=True
|
||||
)
|
||||
response_data.append(
|
||||
{
|
||||
"type": "fingerprint",
|
||||
@@ -460,7 +498,9 @@ class RecipeQueryHandler:
|
||||
"id": recipe.get("id"),
|
||||
"title": recipe.get("title"),
|
||||
"file_url": recipe.get("file_url")
|
||||
or self._format_recipe_file_url(recipe.get("file_path", "")),
|
||||
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", [])),
|
||||
@@ -468,7 +508,9 @@ class RecipeQueryHandler:
|
||||
)
|
||||
|
||||
if len(recipes) >= 2:
|
||||
recipes.sort(key=lambda entry: entry.get("modified", 0), reverse=True)
|
||||
recipes.sort(
|
||||
key=lambda entry: entry.get("modified", 0), reverse=True
|
||||
)
|
||||
response_data.append(
|
||||
{
|
||||
"type": "source_url",
|
||||
@@ -479,9 +521,13 @@ class RecipeQueryHandler:
|
||||
)
|
||||
|
||||
response_data.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
return web.json_response({"success": True, "duplicate_groups": response_data})
|
||||
return web.json_response(
|
||||
{"success": True, "duplicate_groups": response_data}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error finding duplicate recipes: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error finding duplicate recipes: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_recipe_syntax(self, request: web.Request) -> web.Response:
|
||||
@@ -498,9 +544,13 @@ class RecipeQueryHandler:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
if not syntax_parts:
|
||||
return web.json_response({"error": "No LoRAs found in this recipe"}, status=400)
|
||||
return web.json_response(
|
||||
{"error": "No LoRAs found in this recipe"}, status=400
|
||||
)
|
||||
|
||||
return web.json_response({"success": True, "syntax": " ".join(syntax_parts)})
|
||||
return web.json_response(
|
||||
{"success": True, "syntax": " ".join(syntax_parts)}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error generating recipe syntax: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
@@ -561,11 +611,17 @@ class RecipeManagementHandler:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
# Check if already running
|
||||
if self._ws_manager.is_recipe_repair_running():
|
||||
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe repair already in progress"},
|
||||
status=409,
|
||||
)
|
||||
|
||||
recipe_scanner.reset_cancellation()
|
||||
|
||||
@@ -579,11 +635,12 @@ class RecipeManagementHandler:
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
|
||||
await self._ws_manager.broadcast_recipe_repair_progress({
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
})
|
||||
self._logger.error(
|
||||
f"Error in recipe repair task: {e}", exc_info=True
|
||||
)
|
||||
await self._ws_manager.broadcast_recipe_repair_progress(
|
||||
{"status": "error", "error": str(e)}
|
||||
)
|
||||
finally:
|
||||
# Keep the final status for a while so the UI can see it
|
||||
await asyncio.sleep(5)
|
||||
@@ -593,7 +650,9 @@ class RecipeManagementHandler:
|
||||
|
||||
asyncio.create_task(run_repair())
|
||||
|
||||
return web.json_response({"success": True, "message": "Recipe repair started"})
|
||||
return web.json_response(
|
||||
{"success": True, "message": "Recipe repair started"}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -603,10 +662,15 @@ class RecipeManagementHandler:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
recipe_scanner.cancel_task()
|
||||
return web.json_response({"success": True, "message": "Cancellation requested"})
|
||||
return web.json_response(
|
||||
{"success": True, "message": "Cancellation requested"}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -616,7 +680,10 @@ class RecipeManagementHandler:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||
@@ -632,25 +699,26 @@ class RecipeManagementHandler:
|
||||
progress = self._ws_manager.get_recipe_repair_progress()
|
||||
if progress:
|
||||
return web.json_response({"success": True, "progress": progress})
|
||||
return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
|
||||
return web.json_response(
|
||||
{"success": False, "message": "No repair in progress"}, status=404
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
|
||||
# 1. Parse Parameters
|
||||
params = request.rel_url.query
|
||||
image_url = params.get("image_url")
|
||||
name = params.get("name")
|
||||
resources_raw = params.get("resources")
|
||||
|
||||
|
||||
if not image_url:
|
||||
raise RecipeValidationError("Missing required field: image_url")
|
||||
if not name:
|
||||
@@ -658,64 +726,80 @@ class RecipeManagementHandler:
|
||||
if not resources_raw:
|
||||
raise RecipeValidationError("Missing required field: resources")
|
||||
|
||||
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
||||
checkpoint_entry, lora_entries = self._parse_resources_payload(
|
||||
resources_raw
|
||||
)
|
||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||
|
||||
|
||||
# 2. Initial Metadata Construction
|
||||
metadata: Dict[str, Any] = {
|
||||
"base_model": params.get("base_model", "") or "",
|
||||
"loras": lora_entries,
|
||||
"gen_params": gen_params_request or {},
|
||||
"source_url": image_url
|
||||
"source_url": image_url,
|
||||
}
|
||||
|
||||
|
||||
source_path = params.get("source_path")
|
||||
if source_path:
|
||||
metadata["source_path"] = source_path
|
||||
|
||||
|
||||
# Checkpoint handling
|
||||
if checkpoint_entry:
|
||||
metadata["checkpoint"] = checkpoint_entry
|
||||
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
|
||||
# Actually enricher looks at metadata['checkpoint'], so this is fine.
|
||||
|
||||
|
||||
# Try to resolve base model from checkpoint if not explicitly provided
|
||||
if not metadata["base_model"]:
|
||||
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||
base_model_from_metadata = (
|
||||
await self._resolve_base_model_from_checkpoint(checkpoint_entry)
|
||||
)
|
||||
if base_model_from_metadata:
|
||||
metadata["base_model"] = base_model_from_metadata
|
||||
|
||||
tags = self._parse_tags(params.get("tags"))
|
||||
|
||||
|
||||
# 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
|
||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||
# with embedded data if we want it to merge it.
|
||||
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
|
||||
# with embedded data if we want it to merge it.
|
||||
# However, logic in Enricher merges: request > civitai > embedded.
|
||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
|
||||
# OR pass them to enricher to handle?
|
||||
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
|
||||
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
|
||||
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
|
||||
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
|
||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||
# Wait, `Enricher` fetches Civitai info internally based on URL.
|
||||
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
|
||||
|
||||
|
||||
# Let's extract embedded metadata first
|
||||
embedded_gen_params = {}
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=extension, delete=False
|
||||
) as temp_img:
|
||||
temp_img.write(image_bytes)
|
||||
temp_img_path = temp_img.name
|
||||
|
||||
|
||||
try:
|
||||
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
||||
if raw_embedded:
|
||||
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
raw_embedded
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded, recipe_scanner=recipe_scanner
|
||||
)
|
||||
if parsed_embedded and "gen_params" in parsed_embedded:
|
||||
embedded_gen_params = parsed_embedded["gen_params"]
|
||||
else:
|
||||
@@ -724,7 +808,9 @@ class RecipeManagementHandler:
|
||||
if os.path.exists(temp_img_path):
|
||||
os.unlink(temp_img_path)
|
||||
except Exception as exc:
|
||||
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
|
||||
self._logger.warning(
|
||||
"Failed to extract embedded metadata during import: %s", exc
|
||||
)
|
||||
|
||||
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
|
||||
if embedded_gen_params:
|
||||
@@ -732,18 +818,18 @@ class RecipeManagementHandler:
|
||||
# But wait, we want request params to override everything.
|
||||
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
|
||||
metadata["gen_params"] = embedded_gen_params
|
||||
|
||||
|
||||
# 5. Enrich with unified logic
|
||||
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
|
||||
civitai_client = self._civitai_client_getter()
|
||||
await RecipeEnricher.enrich_recipe(
|
||||
recipe=metadata,
|
||||
recipe=metadata,
|
||||
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),
|
||||
# we might want to manually merge it?
|
||||
# we might want to manually merge it?
|
||||
# But usually `import_remote_recipe` is used with Civitai URLs.
|
||||
# For now, relying on Enricher's internal fetch is consistent with repair.
|
||||
|
||||
@@ -762,7 +848,9 @@ class RecipeManagementHandler:
|
||||
except RecipeDownloadError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error importing recipe from remote source: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error importing recipe from remote source: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def delete_recipe(self, request: web.Request) -> web.Response:
|
||||
@@ -816,7 +904,11 @@ class RecipeManagementHandler:
|
||||
target_path = data.get("target_path")
|
||||
if not recipe_id or not target_path:
|
||||
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(
|
||||
@@ -845,7 +937,11 @@ class RecipeManagementHandler:
|
||||
target_path = data.get("target_path")
|
||||
if not recipe_ids or not target_path:
|
||||
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(
|
||||
@@ -934,7 +1030,9 @@ class RecipeManagementHandler:
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error saving recipe from widget: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error saving recipe from widget: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def _parse_save_payload(self, reader) -> dict[str, Any]:
|
||||
@@ -1006,7 +1104,9 @@ class RecipeManagementHandler:
|
||||
raise RecipeValidationError("gen_params payload must be an object")
|
||||
return parsed
|
||||
|
||||
def _parse_resources_payload(self, payload_raw: str) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
def _parse_resources_payload(
|
||||
self, payload_raw: str
|
||||
) -> tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
try:
|
||||
payload = json.loads(payload_raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
@@ -1066,15 +1166,19 @@ class RecipeManagementHandler:
|
||||
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
|
||||
if civitai_match:
|
||||
if civitai_client is None:
|
||||
raise RecipeDownloadError("Civitai client unavailable for image download")
|
||||
raise RecipeDownloadError(
|
||||
"Civitai client unavailable for image download"
|
||||
)
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
if not image_info:
|
||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
)
|
||||
|
||||
media_url = image_info.get("url")
|
||||
if not media_url:
|
||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||
|
||||
|
||||
# Use optimized preview URLs if possible
|
||||
media_type = image_info.get("type")
|
||||
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
|
||||
@@ -1083,18 +1187,24 @@ class RecipeManagementHandler:
|
||||
else:
|
||||
download_url = media_url
|
||||
|
||||
success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
|
||||
success, result = await downloader.download_file(
|
||||
download_url, temp_path, use_auth=False
|
||||
)
|
||||
if not success:
|
||||
raise RecipeDownloadError(f"Failed to download image: {result}")
|
||||
|
||||
|
||||
# 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()
|
||||
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:
|
||||
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
|
||||
return (
|
||||
file_obj.read(),
|
||||
extension,
|
||||
image_info.get("meta") if civitai_match and image_info else None,
|
||||
)
|
||||
except RecipeDownloadError:
|
||||
raise
|
||||
except RecipeValidationError:
|
||||
@@ -1108,14 +1218,15 @@ class RecipeManagementHandler:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _safe_int(self, value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
async def _resolve_base_model_from_checkpoint(self, checkpoint_entry: Dict[str, Any]) -> str:
|
||||
async def _resolve_base_model_from_checkpoint(
|
||||
self, checkpoint_entry: Dict[str, Any]
|
||||
) -> str:
|
||||
version_id = self._safe_int(checkpoint_entry.get("modelVersionId"))
|
||||
|
||||
if not version_id:
|
||||
@@ -1134,7 +1245,9 @@ class RecipeManagementHandler:
|
||||
base_model = version_info.get("baseModel") or ""
|
||||
return str(base_model) if base_model is not None else ""
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
self._logger.warning("Failed to resolve base model from checkpoint metadata: %s", exc)
|
||||
self._logger.warning(
|
||||
"Failed to resolve base model from checkpoint metadata: %s", exc
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
@@ -1279,5 +1392,311 @@ class RecipeSharingHandler:
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error downloading shared recipe: %s", exc, exc_info=True)
|
||||
self._logger.error(
|
||||
"Error downloading shared recipe: %s", exc, exc_info=True
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -38,12 +38,24 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
|
||||
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
|
||||
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
|
||||
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
|
||||
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
|
||||
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
|
||||
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-metadata-archive", "download_metadata_archive"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/model-versions-status", "get_model_versions_status"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"),
|
||||
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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,7 +79,11 @@ class MiscRouteRegistrar:
|
||||
definitions: Iterable[RouteDefinition] = MISC_ROUTE_DEFINITIONS,
|
||||
) -> None:
|
||||
for definition in definitions:
|
||||
self._bind(definition.method, definition.path, handler_lookup[definition.handler_name])
|
||||
self._bind(
|
||||
definition.method,
|
||||
definition.path,
|
||||
handler_lookup[definition.handler_name],
|
||||
)
|
||||
|
||||
def _bind(self, method: str, path: str, handler: Callable) -> None:
|
||||
add_method_name = self._METHOD_MAP[method.upper()]
|
||||
|
||||
@@ -19,6 +19,7 @@ from ..services.downloader import get_downloader
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from .handlers.misc_handlers import (
|
||||
CustomWordsHandler,
|
||||
ExampleWorkflowsHandler,
|
||||
FileSystemHandler,
|
||||
HealthCheckHandler,
|
||||
LoraCodeHandler,
|
||||
@@ -38,9 +39,10 @@ from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get(
|
||||
"HF_HUB_DISABLE_TELEMETRY", "0"
|
||||
) == "0"
|
||||
standalone_mode = (
|
||||
os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||
or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
|
||||
)
|
||||
|
||||
|
||||
class MiscRoutes:
|
||||
@@ -75,7 +77,9 @@ class MiscRoutes:
|
||||
self._node_registry = node_registry or NodeRegistry()
|
||||
self._standalone_mode = standalone_mode_flag
|
||||
|
||||
self._handler_mapping: Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None = None
|
||||
self._handler_mapping: (
|
||||
Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]] | None
|
||||
) = None
|
||||
|
||||
@staticmethod
|
||||
def setup_routes(app: web.Application) -> None:
|
||||
@@ -87,7 +91,9 @@ class MiscRoutes:
|
||||
registrar = self._registrar_factory(app)
|
||||
registrar.register_routes(self._ensure_handler_mapping())
|
||||
|
||||
def _ensure_handler_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
def _ensure_handler_mapping(
|
||||
self,
|
||||
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
if self._handler_mapping is None:
|
||||
handler_set = self._create_handler_set()
|
||||
self._handler_mapping = handler_set.to_route_mapping()
|
||||
@@ -121,6 +127,7 @@ class MiscRoutes:
|
||||
)
|
||||
custom_words = CustomWordsHandler()
|
||||
supporters = SupportersHandler()
|
||||
example_workflows = ExampleWorkflowsHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -135,6 +142,7 @@ class MiscRoutes:
|
||||
filesystem=filesystem,
|
||||
custom_words=custom_words,
|
||||
supporters=supporters,
|
||||
example_workflows=example_workflows,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Route registrar for recipe endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -22,7 +23,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}", "get_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-local-image", "analyze_local_image"),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipes/analyze-local-image", "analyze_local_image"
|
||||
),
|
||||
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||
@@ -30,9 +33,13 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||
RouteDefinition(
|
||||
"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/download", "download_shared_recipe"),
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||
@@ -40,13 +47,26 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
||||
RouteDefinition(
|
||||
"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/scan", "scan_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -63,7 +83,9 @@ class RecipeRouteRegistrar:
|
||||
def __init__(self, app: web.Application) -> None:
|
||||
self._app = app
|
||||
|
||||
def register_routes(self, handler_lookup: Mapping[str, Callable[[web.Request], object]]) -> None:
|
||||
def register_routes(
|
||||
self, handler_lookup: Mapping[str, Callable[[web.Request], object]]
|
||||
) -> None:
|
||||
for definition in ROUTE_DEFINITIONS:
|
||||
handler = handler_lookup[definition.handler_name]
|
||||
self._bind_route(definition.method, definition.path, handler)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
|
||||
import logging
|
||||
import os
|
||||
@@ -207,7 +208,11 @@ class BaseModelService(ABC):
|
||||
|
||||
reverse = sort_params.order == "desc"
|
||||
annotated.sort(
|
||||
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
|
||||
key=lambda x: (
|
||||
x.get("usage_count", 0),
|
||||
x.get("model_name", "").lower(),
|
||||
x.get("file_path", "").lower()
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
return annotated
|
||||
@@ -383,7 +388,9 @@ class BaseModelService(ABC):
|
||||
# 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))
|
||||
hide_early_access = bool(
|
||||
self.settings.get("hide_early_access_updates", False)
|
||||
)
|
||||
except Exception:
|
||||
hide_early_access = False
|
||||
|
||||
@@ -413,7 +420,11 @@ class BaseModelService(ABC):
|
||||
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
|
||||
if callable(bulk_method):
|
||||
try:
|
||||
resolved = await bulk_method(self.model_type, ordered_ids, hide_early_access=hide_early_access)
|
||||
resolved = await bulk_method(
|
||||
self.model_type,
|
||||
ordered_ids,
|
||||
hide_early_access=hide_early_access,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Failed to resolve update status in bulk for %s models (%s): %s",
|
||||
@@ -426,7 +437,9 @@ class BaseModelService(ABC):
|
||||
|
||||
if resolved is None:
|
||||
tasks = [
|
||||
self.update_service.has_update(self.model_type, model_id, hide_early_access=hide_early_access)
|
||||
self.update_service.has_update(
|
||||
self.model_type, model_id, hide_early_access=hide_early_access
|
||||
)
|
||||
for model_id in ordered_ids
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -588,13 +601,19 @@ class BaseModelService(ABC):
|
||||
normalized_type = normalize_sub_type(resolve_sub_type(entry))
|
||||
if not normalized_type:
|
||||
continue
|
||||
|
||||
|
||||
# Filter by valid sub-types based on scanner type
|
||||
if self.model_type == "lora" and normalized_type not in VALID_LORA_SUB_TYPES:
|
||||
if (
|
||||
self.model_type == "lora"
|
||||
and normalized_type not in VALID_LORA_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
if self.model_type == "checkpoint" and normalized_type not in VALID_CHECKPOINT_SUB_TYPES:
|
||||
if (
|
||||
self.model_type == "checkpoint"
|
||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
|
||||
|
||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||
|
||||
sorted_types = sorted(
|
||||
@@ -807,38 +826,61 @@ class BaseModelService(ABC):
|
||||
|
||||
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
|
||||
def _relative_path_matches_tokens(
|
||||
path_lower: str, include_terms: List[str], exclude_terms: List[str]
|
||||
) -> bool:
|
||||
"""Determine whether a relative path string satisfies include/exclude tokens."""
|
||||
if any(term and term in path_lower for term in exclude_terms):
|
||||
"""Determine whether a relative path string satisfies include/exclude tokens.
|
||||
|
||||
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
|
||||
|
||||
for term in include_terms:
|
||||
if term and term not in path_lower:
|
||||
if term and term not in path_for_matching:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
|
||||
"""Sort paths by how well they satisfy the include tokens."""
|
||||
path_lower = relative_path.lower()
|
||||
"""Sort paths by how well they satisfy the include tokens.
|
||||
|
||||
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(
|
||||
1 for term in include_terms if term and path_lower.startswith(term)
|
||||
1 for term in include_terms if term and path_for_sorting.startswith(term)
|
||||
)
|
||||
match_positions = [
|
||||
path_lower.find(term)
|
||||
path_for_sorting.find(term)
|
||||
for term in include_terms
|
||||
if term and term in path_lower
|
||||
if term and term in path_for_sorting
|
||||
]
|
||||
first_match_index = min(match_positions) if match_positions else 0
|
||||
|
||||
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
|
||||
return (
|
||||
-prefix_hits,
|
||||
first_match_index,
|
||||
len(path_for_sorting),
|
||||
path_for_sorting,
|
||||
)
|
||||
|
||||
async def search_relative_paths(
|
||||
self, search_term: str, limit: int = 15
|
||||
self, search_term: str, limit: int = 15, offset: int = 0
|
||||
) -> List[str]:
|
||||
"""Search model relative file paths for autocomplete functionality"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
@@ -849,6 +891,7 @@ class BaseModelService(ABC):
|
||||
# Get model roots for path calculation
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
# Collect all matching paths first (needed for proper sorting and offset)
|
||||
for model in cache.raw_data:
|
||||
file_path = model.get("file_path", "")
|
||||
if not file_path:
|
||||
@@ -877,12 +920,12 @@ class BaseModelService(ABC):
|
||||
):
|
||||
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)
|
||||
matching_paths.sort(
|
||||
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
|
||||
)
|
||||
|
||||
return matching_paths[:limit]
|
||||
# Apply offset and limit
|
||||
start = min(offset, len(matching_paths))
|
||||
end = min(start + limit, len(matching_paths))
|
||||
return matching_paths[start:end]
|
||||
|
||||
593
py/services/batch_import_service.py
Normal file
593
py/services/batch_import_service.py
Normal file
@@ -0,0 +1,593 @@
|
||||
"""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]
|
||||
@@ -58,6 +58,7 @@ class CacheEntryValidator:
|
||||
'preview_nsfw_level': (0, False),
|
||||
'notes': ('', False),
|
||||
'usage_tips': ('', False),
|
||||
'hash_status': ('completed', False),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -90,13 +91,31 @@ class CacheEntryValidator:
|
||||
|
||||
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():
|
||||
value = working_entry.get(field_name)
|
||||
# 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:
|
||||
@@ -107,6 +126,10 @@ class CacheEntryValidator:
|
||||
# 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)
|
||||
@@ -127,7 +150,7 @@ class CacheEntryValidator:
|
||||
# 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', '')
|
||||
hash_status = working_entry.get('hash_status', 'completed')
|
||||
# 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':
|
||||
@@ -144,8 +167,13 @@ class CacheEntryValidator:
|
||||
if isinstance(sha256, str):
|
||||
normalized_sha = sha256.lower().strip()
|
||||
if normalized_sha != sha256:
|
||||
working_entry['sha256'] = normalized_sha
|
||||
repaired = True
|
||||
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
|
||||
|
||||
@@ -13,22 +13,35 @@ from .model_hash_index import ModelHashIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CheckpointScanner(ModelScanner):
|
||||
"""Service for scanning and managing checkpoint files"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
# Define supported file extensions
|
||||
file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.gguf'}
|
||||
file_extensions = {
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".safetensors",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
super().__init__(
|
||||
model_type="checkpoint",
|
||||
model_class=CheckpointMetadata,
|
||||
file_extensions=file_extensions,
|
||||
hash_index=ModelHashIndex()
|
||||
hash_index=ModelHashIndex(),
|
||||
)
|
||||
|
||||
async def _create_default_metadata(self, file_path: str) -> Optional[CheckpointMetadata]:
|
||||
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.
|
||||
@@ -38,13 +51,13 @@ class CheckpointScanner(ModelScanner):
|
||||
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,
|
||||
@@ -59,70 +72,76 @@ class CheckpointScanner(ModelScanner):
|
||||
modelDescription="",
|
||||
sub_type="checkpoint",
|
||||
from_civitai=False, # Mark as local model since no hash yet
|
||||
hash_status="pending" # Mark hash as pending
|
||||
hash_status="pending", # Mark hash as pending
|
||||
)
|
||||
|
||||
|
||||
# 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}")
|
||||
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)
|
||||
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)
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if metadata:
|
||||
metadata.hash_status = "failed"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
@@ -130,43 +149,46 @@ class CheckpointScanner(ModelScanner):
|
||||
pass
|
||||
return None
|
||||
|
||||
async def calculate_all_pending_hashes(self, progress_callback=None) -> Dict[str, int]:
|
||||
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')
|
||||
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}
|
||||
|
||||
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')
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
sha256 = await self.calculate_hash_for_model(file_path)
|
||||
if sha256:
|
||||
@@ -176,77 +198,102 @@ class CheckpointScanner(ModelScanner):
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
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'):
|
||||
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:
|
||||
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:
|
||||
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_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']}
|
||||
})
|
||||
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}")
|
||||
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]:
|
||||
"""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:
|
||||
return None
|
||||
|
||||
# Check standard ComfyUI checkpoint paths
|
||||
if config.checkpoints_roots and root_path in config.checkpoints_roots:
|
||||
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:
|
||||
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
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
|
||||
@@ -490,14 +490,33 @@ class CivitaiClient:
|
||||
"""
|
||||
try:
|
||||
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||
requested_id = int(image_id)
|
||||
|
||||
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||
success, result = await self._make_request("GET", url, use_auth=True)
|
||||
|
||||
if success:
|
||||
if result and "items" in result and len(result["items"]) > 0:
|
||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||
return result["items"][0]
|
||||
if result and "items" in result and isinstance(result["items"], list):
|
||||
items = result["items"]
|
||||
|
||||
# First, try to find the item with matching ID
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("id") == requested_id:
|
||||
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||
return item
|
||||
|
||||
# No matching ID found - log warning with details about returned items
|
||||
returned_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and "id" in item
|
||||
]
|
||||
logger.warning(
|
||||
f"CivitAI API returned no matching image for requested ID {image_id}. "
|
||||
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
|
||||
f"This may indicate the image was deleted, hidden, or there is a database lag."
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(f"No image found with ID: {image_id}")
|
||||
return None
|
||||
|
||||
@@ -505,6 +524,10 @@ class CivitaiClient:
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except ValueError as e:
|
||||
error_msg = f"Invalid image ID format: {image_id}"
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching image info: {e}"
|
||||
logger.error(error_msg)
|
||||
|
||||
@@ -49,6 +49,7 @@ class CustomWordsService:
|
||||
if self._tag_index is None:
|
||||
try:
|
||||
from .tag_fts_index import get_tag_fts_index
|
||||
|
||||
self._tag_index = get_tag_fts_index()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TagFTSIndex: {e}")
|
||||
@@ -59,14 +60,16 @@ class CustomWordsService:
|
||||
self,
|
||||
search_term: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
categories: Optional[List[int]] = None,
|
||||
enriched: bool = False
|
||||
enriched: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search tags using TagFTSIndex with category filtering.
|
||||
|
||||
Args:
|
||||
search_term: The search term to match against.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
enriched: If True, always return enriched results with category
|
||||
and post_count (default behavior now).
|
||||
@@ -76,7 +79,9 @@ class CustomWordsService:
|
||||
"""
|
||||
tag_index = self._get_tag_index()
|
||||
if tag_index is not None:
|
||||
results = tag_index.search(search_term, categories=categories, limit=limit)
|
||||
results = tag_index.search(
|
||||
search_term, categories=categories, limit=limit, offset=offset
|
||||
)
|
||||
return results
|
||||
|
||||
logger.debug("TagFTSIndex not available, returning empty results")
|
||||
|
||||
@@ -10,12 +10,15 @@ import uuid
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from urllib.parse import urlparse
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, DIFFUSION_MODEL_BASE_MODELS, VALID_LORA_TYPES
|
||||
from ..utils.constants import (
|
||||
CARD_PREVIEW_WIDTH,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.file_utils import calculate_sha256
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
from .settings_manager import get_settings_manager
|
||||
@@ -352,10 +355,12 @@ class DownloadManager:
|
||||
# Check if this checkpoint should be treated as a diffusion model based on baseModel
|
||||
is_diffusion_model = False
|
||||
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:
|
||||
is_diffusion_model = True
|
||||
logger.info(f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder")
|
||||
logger.info(
|
||||
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
|
||||
if model_version_id is None:
|
||||
@@ -464,7 +469,7 @@ class DownloadManager:
|
||||
# 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")
|
||||
@@ -472,23 +477,28 @@ class DownloadManager:
|
||||
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(
|
||||
(f for f in files if f.get("primary") and f.get("type") in ("Model", "Negative")),
|
||||
None
|
||||
(
|
||||
f
|
||||
for f in 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
|
||||
@@ -496,10 +506,10 @@ class DownloadManager:
|
||||
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(
|
||||
@@ -510,7 +520,7 @@ class DownloadManager:
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
if not file_info:
|
||||
return {"success": False, "error": "No suitable file found in metadata"}
|
||||
mirrors = file_info.get("mirrors") or []
|
||||
@@ -836,9 +846,13 @@ class DownloadManager:
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
|
||||
)
|
||||
selected_image, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
|
||||
preview_url = selected_image.get("url") if selected_image else None
|
||||
@@ -954,11 +968,12 @@ class DownloadManager:
|
||||
for download_url in download_urls:
|
||||
use_auth = download_url.startswith("https://civitai.com/api/download/")
|
||||
download_kwargs = {
|
||||
"progress_callback": lambda progress,
|
||||
snapshot=None: self._handle_download_progress(
|
||||
progress,
|
||||
progress_callback,
|
||||
snapshot,
|
||||
"progress_callback": lambda progress, snapshot=None: (
|
||||
self._handle_download_progress(
|
||||
progress,
|
||||
progress_callback,
|
||||
snapshot,
|
||||
)
|
||||
),
|
||||
"use_auth": use_auth, # Only use authentication for Civitai downloads
|
||||
}
|
||||
@@ -1220,8 +1235,15 @@ class DownloadManager:
|
||||
entries: List = []
|
||||
for index, file_path in enumerate(file_paths):
|
||||
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
|
||||
entry.update_file_info(file_path)
|
||||
entry.sha256 = await calculate_sha256(file_path)
|
||||
# Update file paths without modifying size and modified timestamps
|
||||
# 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)
|
||||
# Use SHA256 from API metadata (already set in from_civitai_info)
|
||||
# Do not recalculate to avoid blocking during ComfyUI execution
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
@@ -44,7 +44,9 @@ class DownloadStreamControl:
|
||||
self._event.set()
|
||||
self._reconnect_requested = False
|
||||
self.last_progress_timestamp: Optional[float] = None
|
||||
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0
|
||||
self.stall_timeout: float = (
|
||||
float(stall_timeout) if stall_timeout is not None else 120.0
|
||||
)
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._event.is_set()
|
||||
@@ -85,7 +87,9 @@ class DownloadStreamControl:
|
||||
self.last_progress_timestamp = timestamp or datetime.now().timestamp()
|
||||
self._reconnect_requested = False
|
||||
|
||||
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]:
|
||||
def time_since_last_progress(
|
||||
self, *, now: Optional[float] = None
|
||||
) -> Optional[float]:
|
||||
if self.last_progress_timestamp is None:
|
||||
return None
|
||||
reference = now if now is not None else datetime.now().timestamp()
|
||||
@@ -105,10 +109,10 @@ class DownloadStalledError(Exception):
|
||||
|
||||
class Downloader:
|
||||
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
|
||||
|
||||
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls):
|
||||
"""Get singleton instance of Downloader"""
|
||||
@@ -116,35 +120,37 @@ class Downloader:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the downloader with optimal settings"""
|
||||
# Check if already initialized for singleton pattern
|
||||
if hasattr(self, '_initialized'):
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
|
||||
# Session management
|
||||
self._session = None
|
||||
self._session_created_at = None
|
||||
self._proxy_url = None # Store proxy URL for current session
|
||||
self._session_lock = asyncio.Lock()
|
||||
|
||||
|
||||
# Configuration
|
||||
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
|
||||
self.chunk_size = (
|
||||
16 * 1024 * 1024
|
||||
) # 16MB chunks to balance I/O reduction and memory usage
|
||||
self.max_retries = 5
|
||||
self.base_delay = 2.0 # Base delay for exponential backoff
|
||||
self.session_timeout = 300 # 5 minutes
|
||||
self.stall_timeout = self._resolve_stall_timeout()
|
||||
|
||||
|
||||
# Default headers
|
||||
self.default_headers = {
|
||||
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
|
||||
"User-Agent": "ComfyUI-LoRA-Manager/1.0",
|
||||
# Explicitly request uncompressed payloads so aiohttp doesn't need optional
|
||||
# decoders (e.g. zstandard) that may be missing in runtime environments.
|
||||
'Accept-Encoding': 'identity',
|
||||
"Accept-Encoding": "identity",
|
||||
}
|
||||
|
||||
|
||||
@property
|
||||
async def session(self) -> aiohttp.ClientSession:
|
||||
"""Get or create the global aiohttp session with optimized settings"""
|
||||
@@ -158,7 +164,7 @@ class Downloader:
|
||||
@property
|
||||
def proxy_url(self) -> Optional[str]:
|
||||
"""Get the current proxy URL (initialize if needed)"""
|
||||
if not hasattr(self, '_proxy_url'):
|
||||
if not hasattr(self, "_proxy_url"):
|
||||
self._proxy_url = None
|
||||
return self._proxy_url
|
||||
|
||||
@@ -169,14 +175,14 @@ class Downloader:
|
||||
|
||||
try:
|
||||
settings_manager = get_settings_manager()
|
||||
settings_timeout = settings_manager.get('download_stall_timeout_seconds')
|
||||
settings_timeout = settings_manager.get("download_stall_timeout_seconds")
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
logger.debug("Failed to read stall timeout from settings: %s", exc)
|
||||
|
||||
raw_value = (
|
||||
settings_timeout
|
||||
if settings_timeout not in (None, "")
|
||||
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT')
|
||||
else os.environ.get("COMFYUI_DOWNLOAD_STALL_TIMEOUT")
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -190,93 +196,104 @@ class Downloader:
|
||||
"""Check if session should be refreshed"""
|
||||
if self._session is None:
|
||||
return True
|
||||
|
||||
if not hasattr(self, '_session_created_at') or self._session_created_at is None:
|
||||
|
||||
if not hasattr(self, "_session_created_at") or self._session_created_at is None:
|
||||
return True
|
||||
|
||||
|
||||
# Refresh if session is older than timeout
|
||||
if (datetime.now() - self._session_created_at).total_seconds() > self.session_timeout:
|
||||
if (
|
||||
datetime.now() - self._session_created_at
|
||||
).total_seconds() > self.session_timeout:
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _create_session(self):
|
||||
"""Create a new aiohttp session with optimized settings.
|
||||
|
||||
|
||||
Note: This is private and caller MUST hold self._session_lock.
|
||||
"""
|
||||
# Close existing session if any
|
||||
if self._session is not None:
|
||||
try:
|
||||
await self._session.close()
|
||||
except Exception as e: # pragma: no cover
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"Error closing previous session: {e}")
|
||||
finally:
|
||||
self._session = None
|
||||
|
||||
|
||||
# Check for app-level proxy settings
|
||||
proxy_url = None
|
||||
settings_manager = get_settings_manager()
|
||||
if settings_manager.get('proxy_enabled', False):
|
||||
proxy_host = settings_manager.get('proxy_host', '').strip()
|
||||
proxy_port = settings_manager.get('proxy_port', '').strip()
|
||||
proxy_type = settings_manager.get('proxy_type', 'http').lower()
|
||||
proxy_username = settings_manager.get('proxy_username', '').strip()
|
||||
proxy_password = settings_manager.get('proxy_password', '').strip()
|
||||
|
||||
if settings_manager.get("proxy_enabled", False):
|
||||
proxy_host = settings_manager.get("proxy_host", "").strip()
|
||||
proxy_port = settings_manager.get("proxy_port", "").strip()
|
||||
proxy_type = settings_manager.get("proxy_type", "http").lower()
|
||||
proxy_username = settings_manager.get("proxy_username", "").strip()
|
||||
proxy_password = settings_manager.get("proxy_password", "").strip()
|
||||
|
||||
if proxy_host and proxy_port:
|
||||
# Build proxy URL
|
||||
if proxy_username and proxy_password:
|
||||
proxy_url = f"{proxy_type}://{proxy_username}:{proxy_password}@{proxy_host}:{proxy_port}"
|
||||
else:
|
||||
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
|
||||
|
||||
logger.debug(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}")
|
||||
|
||||
logger.debug(
|
||||
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
|
||||
)
|
||||
logger.debug("Proxy mode: app-level proxy is active.")
|
||||
else:
|
||||
logger.debug("Proxy mode: system-level proxy (trust_env) will be used if configured in environment.")
|
||||
logger.debug(
|
||||
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
|
||||
)
|
||||
# Optimize TCP connection parameters
|
||||
connector = aiohttp.TCPConnector(
|
||||
ssl=True,
|
||||
limit=8, # Concurrent connections
|
||||
ttl_dns_cache=300, # DNS cache timeout
|
||||
force_close=False, # Keep connections for reuse
|
||||
enable_cleanup_closed=True
|
||||
enable_cleanup_closed=True,
|
||||
)
|
||||
|
||||
|
||||
# Configure timeout parameters
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=None, # No total timeout for large downloads
|
||||
connect=60, # Connection timeout
|
||||
sock_read=300 # 5 minute socket read timeout
|
||||
sock_read=300, # 5 minute socket read timeout
|
||||
)
|
||||
|
||||
|
||||
self._session = aiohttp.ClientSession(
|
||||
connector=connector,
|
||||
trust_env=proxy_url is None, # Only use system proxy if no app-level proxy is set
|
||||
timeout=timeout
|
||||
trust_env=proxy_url
|
||||
is None, # Only use system proxy if no app-level proxy is set
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
# Store proxy URL for use in requests
|
||||
self._proxy_url = proxy_url
|
||||
self._session_created_at = datetime.now()
|
||||
|
||||
logger.debug("Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s", bool(proxy_url), proxy_url is None)
|
||||
|
||||
|
||||
logger.debug(
|
||||
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
|
||||
bool(proxy_url),
|
||||
proxy_url is None,
|
||||
)
|
||||
|
||||
def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]:
|
||||
"""Get headers with optional authentication"""
|
||||
headers = self.default_headers.copy()
|
||||
|
||||
|
||||
if use_auth:
|
||||
# Add CivitAI API key if available
|
||||
settings_manager = get_settings_manager()
|
||||
api_key = settings_manager.get('civitai_api_key')
|
||||
api_key = settings_manager.get("civitai_api_key")
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
async def download_file(
|
||||
self,
|
||||
url: str,
|
||||
@@ -289,7 +306,7 @@ class Downloader:
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Download a file with resumable downloads and retry mechanism
|
||||
|
||||
|
||||
Args:
|
||||
url: Download URL
|
||||
save_path: Full path where the file should be saved
|
||||
@@ -298,75 +315,96 @@ class Downloader:
|
||||
custom_headers: Additional headers to include in request
|
||||
allow_resume: Whether to support resumable downloads
|
||||
pause_event: Optional stream control used to pause/resume and request reconnects
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (success, save_path or error message)
|
||||
"""
|
||||
retry_count = 0
|
||||
part_path = save_path + '.part' if allow_resume else save_path
|
||||
|
||||
part_path = save_path + ".part" if allow_resume else save_path
|
||||
|
||||
# Prepare headers
|
||||
headers = self._get_auth_headers(use_auth)
|
||||
if custom_headers:
|
||||
headers.update(custom_headers)
|
||||
|
||||
|
||||
# Get existing file size for resume
|
||||
resume_offset = 0
|
||||
if allow_resume and os.path.exists(part_path):
|
||||
resume_offset = os.path.getsize(part_path)
|
||||
logger.info(f"Resuming download from offset {resume_offset} bytes")
|
||||
|
||||
|
||||
total_size = 0
|
||||
|
||||
|
||||
while retry_count <= self.max_retries:
|
||||
try:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
if self.proxy_url:
|
||||
logger.debug(f"[download_file] Using app-level proxy: {self.proxy_url}")
|
||||
logger.debug(
|
||||
f"[download_file] Using app-level proxy: {self.proxy_url}"
|
||||
)
|
||||
else:
|
||||
logger.debug("[download_file] Using system-level proxy (trust_env) if configured.")
|
||||
|
||||
logger.debug(
|
||||
"[download_file] Using system-level proxy (trust_env) if configured."
|
||||
)
|
||||
|
||||
# Add Range header for resume if we have partial data
|
||||
request_headers = headers.copy()
|
||||
if allow_resume and resume_offset > 0:
|
||||
request_headers['Range'] = f'bytes={resume_offset}-'
|
||||
|
||||
request_headers["Range"] = f"bytes={resume_offset}-"
|
||||
|
||||
# Disable compression for better chunked downloads
|
||||
request_headers['Accept-Encoding'] = 'identity'
|
||||
|
||||
logger.debug(f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}")
|
||||
request_headers["Accept-Encoding"] = "identity"
|
||||
|
||||
logger.debug(
|
||||
f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}"
|
||||
)
|
||||
if resume_offset > 0:
|
||||
logger.debug(f"Requesting range from byte {resume_offset}")
|
||||
|
||||
async with session.get(url, headers=request_headers, allow_redirects=True, proxy=self.proxy_url) as response:
|
||||
|
||||
async with session.get(
|
||||
url,
|
||||
headers=request_headers,
|
||||
allow_redirects=True,
|
||||
proxy=self.proxy_url,
|
||||
) as response:
|
||||
# Handle different response codes
|
||||
if response.status == 200:
|
||||
# Full content response
|
||||
if resume_offset > 0:
|
||||
# Server doesn't support ranges, restart from beginning
|
||||
logger.warning("Server doesn't support range requests, restarting download")
|
||||
logger.warning(
|
||||
"Server doesn't support range requests, restarting download"
|
||||
)
|
||||
resume_offset = 0
|
||||
if os.path.exists(part_path):
|
||||
os.remove(part_path)
|
||||
elif response.status == 206:
|
||||
# Partial content response (resume successful)
|
||||
content_range = response.headers.get('Content-Range')
|
||||
content_range = response.headers.get("Content-Range")
|
||||
if content_range:
|
||||
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
|
||||
range_parts = content_range.split('/')
|
||||
range_parts = content_range.split("/")
|
||||
if len(range_parts) == 2:
|
||||
total_size = int(range_parts[1])
|
||||
logger.info(f"Successfully resumed download from byte {resume_offset}")
|
||||
logger.info(
|
||||
f"Successfully resumed download from byte {resume_offset}"
|
||||
)
|
||||
elif response.status == 416:
|
||||
# Range not satisfiable - file might be complete or corrupted
|
||||
if allow_resume and os.path.exists(part_path):
|
||||
part_size = os.path.getsize(part_path)
|
||||
logger.warning(f"Range not satisfiable. Part file size: {part_size}")
|
||||
logger.warning(
|
||||
f"Range not satisfiable. Part file size: {part_size}"
|
||||
)
|
||||
# Try to get actual file size
|
||||
head_response = await session.head(url, headers=headers, proxy=self.proxy_url)
|
||||
head_response = await session.head(
|
||||
url, headers=headers, proxy=self.proxy_url
|
||||
)
|
||||
if head_response.status == 200:
|
||||
actual_size = int(head_response.headers.get('content-length', 0))
|
||||
actual_size = int(
|
||||
head_response.headers.get("content-length", 0)
|
||||
)
|
||||
if part_size == actual_size:
|
||||
# File is complete, just rename it
|
||||
if allow_resume:
|
||||
@@ -388,25 +426,40 @@ class Downloader:
|
||||
resume_offset = 0
|
||||
continue
|
||||
elif response.status == 401:
|
||||
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
|
||||
return False, "Invalid or missing API key, or early access restriction."
|
||||
logger.warning(
|
||||
f"Unauthorized access to resource: {url} (Status 401)"
|
||||
)
|
||||
return (
|
||||
False,
|
||||
"Invalid or missing API key, or early access restriction.",
|
||||
)
|
||||
elif response.status == 403:
|
||||
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
|
||||
return False, "Access forbidden: You don't have permission to download this file."
|
||||
logger.warning(
|
||||
f"Forbidden access to resource: {url} (Status 403)"
|
||||
)
|
||||
return (
|
||||
False,
|
||||
"Access forbidden: You don't have permission to download this file.",
|
||||
)
|
||||
elif response.status == 404:
|
||||
logger.warning(f"Resource not found: {url} (Status 404)")
|
||||
return False, "File not found - the download link may be invalid or expired."
|
||||
return (
|
||||
False,
|
||||
"File not found - the download link may be invalid or expired.",
|
||||
)
|
||||
else:
|
||||
logger.error(f"Download failed for {url} with status {response.status}")
|
||||
logger.error(
|
||||
f"Download failed for {url} with status {response.status}"
|
||||
)
|
||||
return False, f"Download failed with status {response.status}"
|
||||
|
||||
|
||||
# Get total file size for progress calculation (if not set from Content-Range)
|
||||
if total_size == 0:
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
if response.status == 206:
|
||||
# For partial content, add the offset to get total file size
|
||||
total_size += resume_offset
|
||||
|
||||
|
||||
current_size = resume_offset
|
||||
last_progress_report_time = datetime.now()
|
||||
progress_samples: deque[tuple[datetime, int]] = deque()
|
||||
@@ -417,7 +470,7 @@ class Downloader:
|
||||
|
||||
# Stream download to file with progress updates
|
||||
loop = asyncio.get_running_loop()
|
||||
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb'
|
||||
mode = "ab" if (allow_resume and resume_offset > 0) else "wb"
|
||||
control = pause_event
|
||||
|
||||
if control is not None:
|
||||
@@ -425,7 +478,9 @@ class Downloader:
|
||||
|
||||
with open(part_path, mode) as f:
|
||||
while True:
|
||||
active_stall_timeout = control.stall_timeout if control else self.stall_timeout
|
||||
active_stall_timeout = (
|
||||
control.stall_timeout if control else self.stall_timeout
|
||||
)
|
||||
|
||||
if control is not None:
|
||||
if control.is_paused():
|
||||
@@ -437,7 +492,9 @@ class Downloader:
|
||||
"Reconnect requested after resume"
|
||||
)
|
||||
elif control.consume_reconnect_request():
|
||||
raise DownloadRestartRequested("Reconnect requested")
|
||||
raise DownloadRestartRequested(
|
||||
"Reconnect requested"
|
||||
)
|
||||
|
||||
try:
|
||||
chunk = await asyncio.wait_for(
|
||||
@@ -466,22 +523,32 @@ class Downloader:
|
||||
control.mark_progress(timestamp=now.timestamp())
|
||||
|
||||
# Limit progress update frequency to reduce overhead
|
||||
time_diff = (now - last_progress_report_time).total_seconds()
|
||||
time_diff = (
|
||||
now - last_progress_report_time
|
||||
).total_seconds()
|
||||
|
||||
if progress_callback and time_diff >= 1.0:
|
||||
progress_samples.append((now, current_size))
|
||||
cutoff = now - timedelta(seconds=5)
|
||||
while progress_samples and progress_samples[0][0] < cutoff:
|
||||
while (
|
||||
progress_samples and progress_samples[0][0] < cutoff
|
||||
):
|
||||
progress_samples.popleft()
|
||||
|
||||
percent = (current_size / total_size) * 100 if total_size else 0.0
|
||||
percent = (
|
||||
(current_size / total_size) * 100
|
||||
if total_size
|
||||
else 0.0
|
||||
)
|
||||
bytes_per_second = 0.0
|
||||
if len(progress_samples) >= 2:
|
||||
first_time, first_bytes = progress_samples[0]
|
||||
last_time, last_bytes = progress_samples[-1]
|
||||
elapsed = (last_time - first_time).total_seconds()
|
||||
if elapsed > 0:
|
||||
bytes_per_second = (last_bytes - first_bytes) / elapsed
|
||||
bytes_per_second = (
|
||||
last_bytes - first_bytes
|
||||
) / elapsed
|
||||
|
||||
progress_snapshot = DownloadProgress(
|
||||
percent_complete=percent,
|
||||
@@ -491,21 +558,23 @@ class Downloader:
|
||||
timestamp=now.timestamp(),
|
||||
)
|
||||
|
||||
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
|
||||
await self._dispatch_progress_callback(
|
||||
progress_callback, progress_snapshot
|
||||
)
|
||||
last_progress_report_time = now
|
||||
|
||||
|
||||
# Download completed successfully
|
||||
# Verify file size integrity before finalizing
|
||||
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0
|
||||
final_size = (
|
||||
os.path.getsize(part_path) if os.path.exists(part_path) else 0
|
||||
)
|
||||
expected_size = total_size if total_size > 0 else None
|
||||
|
||||
integrity_error: Optional[str] = None
|
||||
if final_size <= 0:
|
||||
integrity_error = "Downloaded file is empty"
|
||||
elif expected_size is not None and final_size != expected_size:
|
||||
integrity_error = (
|
||||
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||
)
|
||||
integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
|
||||
|
||||
if integrity_error is not None:
|
||||
logger.error(
|
||||
@@ -554,8 +623,10 @@ class Downloader:
|
||||
max_rename_attempts = 5
|
||||
rename_attempt = 0
|
||||
rename_success = False
|
||||
|
||||
while rename_attempt < max_rename_attempts and not rename_success:
|
||||
|
||||
while (
|
||||
rename_attempt < max_rename_attempts and not rename_success
|
||||
):
|
||||
try:
|
||||
# If the destination file exists, remove it first (Windows safe)
|
||||
if os.path.exists(save_path):
|
||||
@@ -566,11 +637,18 @@ class Downloader:
|
||||
except PermissionError as e:
|
||||
rename_attempt += 1
|
||||
if rename_attempt < max_rename_attempts:
|
||||
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})")
|
||||
logger.info(
|
||||
f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})"
|
||||
)
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
|
||||
return False, f"Failed to finalize download: {str(e)}"
|
||||
logger.error(
|
||||
f"Failed to rename file after {max_rename_attempts} attempts: {e}"
|
||||
)
|
||||
return (
|
||||
False,
|
||||
f"Failed to finalize download: {str(e)}",
|
||||
)
|
||||
|
||||
final_size = os.path.getsize(save_path)
|
||||
|
||||
@@ -583,11 +661,12 @@ class Downloader:
|
||||
bytes_per_second=0.0,
|
||||
timestamp=datetime.now().timestamp(),
|
||||
)
|
||||
await self._dispatch_progress_callback(progress_callback, final_snapshot)
|
||||
await self._dispatch_progress_callback(
|
||||
progress_callback, final_snapshot
|
||||
)
|
||||
|
||||
|
||||
return True, save_path
|
||||
|
||||
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
aiohttp.ClientPayloadError,
|
||||
@@ -597,30 +676,35 @@ class Downloader:
|
||||
DownloadRestartRequested,
|
||||
) as e:
|
||||
retry_count += 1
|
||||
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}")
|
||||
logger.warning(
|
||||
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
|
||||
)
|
||||
|
||||
if retry_count <= self.max_retries:
|
||||
# Calculate delay with exponential backoff
|
||||
delay = self.base_delay * (2 ** (retry_count - 1))
|
||||
logger.info(f"Retrying in {delay} seconds...")
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
|
||||
# Update resume offset for next attempt
|
||||
if allow_resume and os.path.exists(part_path):
|
||||
resume_offset = os.path.getsize(part_path)
|
||||
logger.info(f"Will resume from byte {resume_offset}")
|
||||
|
||||
|
||||
# Refresh session to get new connection
|
||||
await self._create_session()
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Max retries exceeded for download: {e}")
|
||||
return False, f"Network error after {self.max_retries + 1} attempts: {str(e)}"
|
||||
|
||||
return (
|
||||
False,
|
||||
f"Network error after {self.max_retries + 1} attempts: {str(e)}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected download error: {e}")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
return False, f"Download failed after {self.max_retries + 1} attempts"
|
||||
|
||||
async def _dispatch_progress_callback(
|
||||
@@ -645,17 +729,17 @@ class Downloader:
|
||||
url: str,
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
return_headers: bool = False
|
||||
return_headers: bool = False,
|
||||
) -> Tuple[bool, Union[bytes, str], Optional[Dict]]:
|
||||
"""
|
||||
Download a file to memory (for small files like preview images)
|
||||
|
||||
|
||||
Args:
|
||||
url: Download URL
|
||||
use_auth: Whether to include authentication headers
|
||||
custom_headers: Additional headers to include in request
|
||||
return_headers: Whether to return response headers along with content
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
|
||||
"""
|
||||
@@ -663,16 +747,22 @@ class Downloader:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
if self.proxy_url:
|
||||
logger.debug(f"[download_to_memory] Using app-level proxy: {self.proxy_url}")
|
||||
logger.debug(
|
||||
f"[download_to_memory] Using app-level proxy: {self.proxy_url}"
|
||||
)
|
||||
else:
|
||||
logger.debug("[download_to_memory] Using system-level proxy (trust_env) if configured.")
|
||||
|
||||
logger.debug(
|
||||
"[download_to_memory] Using system-level proxy (trust_env) if configured."
|
||||
)
|
||||
|
||||
# Prepare headers
|
||||
headers = self._get_auth_headers(use_auth)
|
||||
if custom_headers:
|
||||
headers.update(custom_headers)
|
||||
|
||||
async with session.get(url, headers=headers, proxy=self.proxy_url) as response:
|
||||
|
||||
async with session.get(
|
||||
url, headers=headers, proxy=self.proxy_url
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
content = await response.read()
|
||||
if return_headers:
|
||||
@@ -691,25 +781,25 @@ class Downloader:
|
||||
else:
|
||||
error_msg = f"Download failed with status {response.status}"
|
||||
return False, error_msg, None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading to memory from {url}: {e}")
|
||||
return False, str(e), None
|
||||
|
||||
|
||||
async def get_response_headers(
|
||||
self,
|
||||
url: str,
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[bool, Union[Dict, str]]:
|
||||
"""
|
||||
Get response headers without downloading the full content
|
||||
|
||||
|
||||
Args:
|
||||
url: URL to check
|
||||
use_auth: Whether to include authentication headers
|
||||
custom_headers: Additional headers to include in request
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Union[Dict, str]]: (success, headers dict or error message)
|
||||
"""
|
||||
@@ -717,43 +807,49 @@ class Downloader:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
if self.proxy_url:
|
||||
logger.debug(f"[get_response_headers] Using app-level proxy: {self.proxy_url}")
|
||||
logger.debug(
|
||||
f"[get_response_headers] Using app-level proxy: {self.proxy_url}"
|
||||
)
|
||||
else:
|
||||
logger.debug("[get_response_headers] Using system-level proxy (trust_env) if configured.")
|
||||
|
||||
logger.debug(
|
||||
"[get_response_headers] Using system-level proxy (trust_env) if configured."
|
||||
)
|
||||
|
||||
# Prepare headers
|
||||
headers = self._get_auth_headers(use_auth)
|
||||
if custom_headers:
|
||||
headers.update(custom_headers)
|
||||
|
||||
async with session.head(url, headers=headers, proxy=self.proxy_url) as response:
|
||||
|
||||
async with session.head(
|
||||
url, headers=headers, proxy=self.proxy_url
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True, dict(response.headers)
|
||||
else:
|
||||
return False, f"Head request failed with status {response.status}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting headers from {url}: {e}")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def make_request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
use_auth: bool = False,
|
||||
custom_headers: Optional[Dict[str, str]] = None,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
) -> Tuple[bool, Union[Dict, str]]:
|
||||
"""
|
||||
Make a generic HTTP request and return JSON response
|
||||
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
url: Request URL
|
||||
use_auth: Whether to include authentication headers
|
||||
custom_headers: Additional headers to include in request
|
||||
**kwargs: Additional arguments for aiohttp request
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
|
||||
"""
|
||||
@@ -763,18 +859,22 @@ class Downloader:
|
||||
if self.proxy_url:
|
||||
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
|
||||
else:
|
||||
logger.debug("[make_request] Using system-level proxy (trust_env) if configured.")
|
||||
|
||||
logger.debug(
|
||||
"[make_request] Using system-level proxy (trust_env) if configured."
|
||||
)
|
||||
|
||||
# Prepare headers
|
||||
headers = self._get_auth_headers(use_auth)
|
||||
if custom_headers:
|
||||
headers.update(custom_headers)
|
||||
|
||||
|
||||
# Add proxy to kwargs if not already present
|
||||
if 'proxy' not in kwargs:
|
||||
kwargs['proxy'] = self.proxy_url
|
||||
|
||||
async with session.request(method, url, headers=headers, **kwargs) as response:
|
||||
if "proxy" not in kwargs:
|
||||
kwargs["proxy"] = self.proxy_url
|
||||
|
||||
async with session.request(
|
||||
method, url, headers=headers, **kwargs
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
# Try to parse as JSON, fall back to text
|
||||
try:
|
||||
@@ -804,11 +904,11 @@ class Downloader:
|
||||
)
|
||||
else:
|
||||
return False, f"Request failed with status {response.status}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error making {method} request to {url}: {e}")
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP session"""
|
||||
if self._session is not None:
|
||||
@@ -817,7 +917,7 @@ class Downloader:
|
||||
self._session_created_at = None
|
||||
self._proxy_url = None
|
||||
logger.debug("Closed HTTP session")
|
||||
|
||||
|
||||
async def refresh_session(self):
|
||||
"""Force refresh the HTTP session (useful when proxy settings change)"""
|
||||
async with self._session_lock:
|
||||
|
||||
@@ -27,7 +27,7 @@ class LoraService(BaseModelService):
|
||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||
# Normalize to lowercase for consistent API responses
|
||||
sub_type = resolve_sub_type(lora_data).lower()
|
||||
|
||||
|
||||
return {
|
||||
"model_name": lora_data["model_name"],
|
||||
"file_name": lora_data["file_name"],
|
||||
@@ -48,7 +48,9 @@ class LoraService(BaseModelService):
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(lora_data.get("skip_metadata_refresh", False)),
|
||||
"skip_metadata_refresh": bool(
|
||||
lora_data.get("skip_metadata_refresh", False)
|
||||
),
|
||||
"sub_type": sub_type,
|
||||
"civitai": self.filter_civitai_data(
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
@@ -62,6 +64,68 @@ class LoraService(BaseModelService):
|
||||
if 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
|
||||
|
||||
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
|
||||
@@ -368,9 +432,7 @@ class LoraService(BaseModelService):
|
||||
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||
)
|
||||
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(
|
||||
{
|
||||
@@ -485,12 +547,69 @@ class LoraService(BaseModelService):
|
||||
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
|
||||
|
||||
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]:
|
||||
"""
|
||||
Get filtered and sorted LoRA list for cycling.
|
||||
@@ -516,12 +635,18 @@ class LoraService(BaseModelService):
|
||||
if sort_by == "model_name":
|
||||
available_loras = sorted(
|
||||
available_loras,
|
||||
key=lambda x: (x.get("model_name") or x.get("file_name", "")).lower()
|
||||
key=lambda x: (
|
||||
(x.get("model_name") or x.get("file_name", "")).lower(),
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
)
|
||||
else: # Default to filename
|
||||
available_loras = sorted(
|
||||
available_loras,
|
||||
key=lambda x: x.get("file_name", "").lower()
|
||||
key=lambda x: (
|
||||
x.get("file_name", "").lower(),
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
)
|
||||
|
||||
# Return minimal data needed for cycling
|
||||
|
||||
@@ -122,11 +122,25 @@ async def get_metadata_provider(provider_name: str = None):
|
||||
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
|
||||
provider = (
|
||||
provider_manager._get_provider(provider_name)
|
||||
if provider_name
|
||||
else provider_manager._get_provider()
|
||||
)
|
||||
try:
|
||||
provider = (
|
||||
provider_manager._get_provider(provider_name)
|
||||
if provider_name
|
||||
else provider_manager._get_provider()
|
||||
)
|
||||
except ValueError as e:
|
||||
# Provider not initialized, attempt to initialize
|
||||
if "No default provider set" in str(e) or "not registered" in str(e):
|
||||
logger.warning(f"Metadata provider not initialized ({e}), initializing now...")
|
||||
await initialize_metadata_providers()
|
||||
provider_manager = await ModelMetadataProviderManager.get_instance()
|
||||
provider = (
|
||||
provider_manager._get_provider(provider_name)
|
||||
if provider_name
|
||||
else provider_manager._get_provider()
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
return _wrap_provider_with_rate_limit(provider_name, provider)
|
||||
|
||||
|
||||
@@ -221,33 +221,45 @@ class ModelCache:
|
||||
start_time = time.perf_counter()
|
||||
reverse = (order == 'desc')
|
||||
if sort_key == 'name':
|
||||
# Natural sort by configured display name, case-insensitive
|
||||
# Natural sort by configured display name, case-insensitive, with file_path as tie-breaker
|
||||
result = natsorted(
|
||||
data,
|
||||
key=lambda x: self._get_display_name(x).lower(),
|
||||
key=lambda x: (
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'date':
|
||||
# Sort by modified timestamp (use .get() with default to handle missing fields)
|
||||
# Sort by modified timestamp, fallback to name and path for stability
|
||||
result = sorted(
|
||||
data,
|
||||
key=lambda x: x.get('modified', 0.0),
|
||||
key=lambda x: (
|
||||
x.get('modified', 0.0),
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'size':
|
||||
# Sort by file size (use .get() with default to handle missing fields)
|
||||
# Sort by file size, fallback to name and path for stability
|
||||
result = sorted(
|
||||
data,
|
||||
key=lambda x: x.get('size', 0),
|
||||
key=lambda x: (
|
||||
x.get('size', 0),
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_key == 'usage':
|
||||
# Sort by usage count, fallback to 0, then name for stability
|
||||
# Sort by usage count, fallback to 0, then name and path for stability
|
||||
return sorted(
|
||||
data,
|
||||
key=lambda x: (
|
||||
x.get('usage_count', 0),
|
||||
self._get_display_name(x).lower()
|
||||
self._get_display_name(x).lower(),
|
||||
x.get('file_path', '').lower()
|
||||
),
|
||||
reverse=reverse
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from .model_lifecycle_service import delete_model_artifacts
|
||||
from .service_registry import ServiceRegistry
|
||||
from .websocket_manager import ws_manager
|
||||
@@ -1442,14 +1441,13 @@ class ModelScanner:
|
||||
file_path = self._hash_index.get_path(sha256.lower())
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
preview_path = f"{base_name}{ext}"
|
||||
if os.path.exists(preview_path):
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
|
||||
dir_path = os.path.dirname(file_path)
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
preview_path = find_preview_file(base_name, dir_path)
|
||||
if preview_path:
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
return None
|
||||
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
|
||||
return None
|
||||
|
||||
blur_mature_content = True
|
||||
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
|
||||
settings = getattr(self, "_settings", None)
|
||||
if settings is not None and hasattr(settings, "get"):
|
||||
try:
|
||||
blur_mature_content = bool(settings.get("blur_mature_content", True))
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings.get("mature_blur_level", "R")}
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive guard
|
||||
blur_mature_content = True
|
||||
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
|
||||
|
||||
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content)
|
||||
selected, _ = select_preview_media(
|
||||
candidates,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
if not selected:
|
||||
return None
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ class PersistentModelCache:
|
||||
"exclude",
|
||||
"db_checked",
|
||||
"last_checked_at",
|
||||
"hash_status",
|
||||
)
|
||||
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
|
||||
_instances: Dict[str, "PersistentModelCache"] = {}
|
||||
@@ -186,6 +187,7 @@ class PersistentModelCache:
|
||||
"civitai_deleted": bool(row["civitai_deleted"]),
|
||||
"skip_metadata_refresh": bool(row["skip_metadata_refresh"]),
|
||||
"license_flags": int(license_value),
|
||||
"hash_status": row["hash_status"] or "completed",
|
||||
}
|
||||
raw_data.append(item)
|
||||
|
||||
@@ -449,6 +451,7 @@ class PersistentModelCache:
|
||||
exclude INTEGER,
|
||||
db_checked INTEGER,
|
||||
last_checked_at REAL,
|
||||
hash_status TEXT,
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
@@ -496,6 +499,7 @@ class PersistentModelCache:
|
||||
"skip_metadata_refresh": "INTEGER DEFAULT 0",
|
||||
# Persisting without explicit flags should assume CivitAI's documented defaults (0b111001 == 57).
|
||||
"license_flags": f"INTEGER DEFAULT {DEFAULT_LICENSE_FLAGS}",
|
||||
"hash_status": "TEXT DEFAULT 'completed'",
|
||||
}
|
||||
|
||||
for column, definition in required_columns.items():
|
||||
@@ -570,6 +574,7 @@ class PersistentModelCache:
|
||||
1 if item.get("exclude") else 0,
|
||||
1 if item.get("db_checked") else 0,
|
||||
float(item.get("last_checked_at") or 0.0),
|
||||
item.get("hash_status", "completed"),
|
||||
)
|
||||
|
||||
def _insert_model_sql(self) -> str:
|
||||
|
||||
@@ -9,7 +9,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import select_preview_media
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -49,9 +49,13 @@ class PreviewAssetService:
|
||||
blur_mature_content = bool(
|
||||
settings_manager.get("blur_mature_content", True)
|
||||
)
|
||||
mature_threshold = resolve_mature_threshold(
|
||||
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
|
||||
)
|
||||
first_preview, nsfw_level = select_preview_media(
|
||||
images,
|
||||
blur_mature_content=blur_mature_content,
|
||||
mature_threshold=mature_threshold,
|
||||
)
|
||||
|
||||
if not first_preview:
|
||||
@@ -216,4 +220,3 @@ class PreviewAssetService:
|
||||
if "webm" in content_type:
|
||||
return ".webm"
|
||||
return ".mp4"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipeCache:
|
||||
"""Cache structure for Recipe data"""
|
||||
@@ -21,11 +22,18 @@ class RecipeCache:
|
||||
self.folder_tree = self.folder_tree or {}
|
||||
|
||||
async def resort(self, name_only: bool = False):
|
||||
"""Resort all cached data views"""
|
||||
"""Resort all cached data views in a thread pool to avoid blocking the event loop."""
|
||||
async with self._lock:
|
||||
self._resort_locked(name_only=name_only)
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._resort_locked,
|
||||
name_only,
|
||||
)
|
||||
|
||||
async def update_recipe_metadata(self, recipe_id: str, metadata: Dict, *, resort: bool = True) -> bool:
|
||||
async def update_recipe_metadata(
|
||||
self, recipe_id: str, metadata: Dict, *, resort: bool = True
|
||||
) -> bool:
|
||||
"""Update metadata for a specific recipe in all cached data
|
||||
|
||||
Args:
|
||||
@@ -37,7 +45,7 @@ class RecipeCache:
|
||||
"""
|
||||
async with self._lock:
|
||||
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)
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
@@ -52,7 +60,9 @@ class RecipeCache:
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
|
||||
async def remove_recipe(self, recipe_id: str, *, resort: bool = False) -> Optional[Dict]:
|
||||
async def remove_recipe(
|
||||
self, recipe_id: str, *, resort: bool = False
|
||||
) -> Optional[Dict]:
|
||||
"""Remove a recipe from the cache by ID.
|
||||
|
||||
Args:
|
||||
@@ -64,14 +74,16 @@ class RecipeCache:
|
||||
|
||||
async with self._lock:
|
||||
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)
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
return removed
|
||||
return None
|
||||
|
||||
async def bulk_remove(self, recipe_ids: Iterable[str], *, resort: bool = False) -> List[Dict]:
|
||||
async def bulk_remove(
|
||||
self, recipe_ids: Iterable[str], *, resort: bool = False
|
||||
) -> List[Dict]:
|
||||
"""Remove multiple recipes from the cache."""
|
||||
|
||||
id_set = {str(recipe_id) for recipe_id in recipe_ids}
|
||||
@@ -79,21 +91,25 @@ class RecipeCache:
|
||||
return []
|
||||
|
||||
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:
|
||||
return []
|
||||
|
||||
self.raw_data = [item for item in self.raw_data if str(item.get('id')) not in id_set]
|
||||
self.raw_data = [
|
||||
item for item in self.raw_data if str(item.get("id")) not in id_set
|
||||
]
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
return removed
|
||||
|
||||
async def replace_recipe(self, recipe_id: str, new_data: Dict, *, resort: bool = False) -> bool:
|
||||
async def replace_recipe(
|
||||
self, recipe_id: str, new_data: Dict, *, resort: bool = False
|
||||
) -> bool:
|
||||
"""Replace cached data for a recipe."""
|
||||
|
||||
async with self._lock:
|
||||
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
|
||||
if resort:
|
||||
self._resort_locked()
|
||||
@@ -105,7 +121,7 @@ class RecipeCache:
|
||||
|
||||
async with self._lock:
|
||||
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 None
|
||||
|
||||
@@ -115,16 +131,14 @@ class RecipeCache:
|
||||
async with self._lock:
|
||||
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``."""
|
||||
|
||||
self.sorted_by_name = natsorted(
|
||||
self.raw_data,
|
||||
key=lambda x: x.get('title', '').lower()
|
||||
key=lambda x: (x.get("title", "").lower(), x.get("file_path", "").lower()),
|
||||
)
|
||||
if not name_only:
|
||||
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
@@ -1,4 +1,5 @@
|
||||
"""Services responsible for recipe metadata analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
@@ -69,7 +70,9 @@ class RecipeAnalysisService:
|
||||
try:
|
||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||
if not metadata:
|
||||
return AnalysisResult({"error": "No metadata found in this image", "loras": []})
|
||||
return AnalysisResult(
|
||||
{"error": "No metadata found in this image", "loras": []}
|
||||
)
|
||||
|
||||
return await self._parse_metadata(
|
||||
metadata,
|
||||
@@ -105,29 +108,33 @@ class RecipeAnalysisService:
|
||||
if civitai_match:
|
||||
image_info = await civitai_client.get_image_info(civitai_match.group(1))
|
||||
if not image_info:
|
||||
raise RecipeDownloadError("Failed to fetch image information from Civitai")
|
||||
|
||||
raise RecipeDownloadError(
|
||||
"Failed to fetch image information from Civitai"
|
||||
)
|
||||
|
||||
image_url = image_info.get("url")
|
||||
if not image_url:
|
||||
raise RecipeDownloadError("No image URL found in Civitai response")
|
||||
|
||||
|
||||
is_video = image_info.get("type") == "video"
|
||||
|
||||
|
||||
# Use optimized preview URLs if possible
|
||||
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
|
||||
rewritten_url, _ = rewrite_preview_url(
|
||||
image_url, media_type=image_info.get("type")
|
||||
)
|
||||
if rewritten_url:
|
||||
image_url = rewritten_url
|
||||
|
||||
if is_video:
|
||||
# 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"
|
||||
else:
|
||||
extension = ".jpg"
|
||||
|
||||
temp_path = self._create_temp_path(suffix=extension)
|
||||
await self._download_image(image_url, temp_path)
|
||||
|
||||
|
||||
metadata = image_info.get("meta") if "meta" in image_info else None
|
||||
if (
|
||||
isinstance(metadata, dict)
|
||||
@@ -135,15 +142,23 @@ class RecipeAnalysisService:
|
||||
and isinstance(metadata["meta"], dict)
|
||||
):
|
||||
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:
|
||||
# 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()
|
||||
if extension in [".mp4", ".webm"]:
|
||||
is_video = True
|
||||
else:
|
||||
extension = ".jpg"
|
||||
|
||||
|
||||
temp_path = self._create_temp_path(suffix=extension)
|
||||
await self._download_image(url, temp_path)
|
||||
|
||||
@@ -211,7 +226,9 @@ class RecipeAnalysisService:
|
||||
|
||||
image_bytes = self._convert_tensor_to_png_bytes(latest_image)
|
||||
if image_bytes is None:
|
||||
raise RecipeValidationError("Cannot handle this data shape from metadata registry")
|
||||
raise RecipeValidationError(
|
||||
"Cannot handle this data shape from metadata registry"
|
||||
)
|
||||
|
||||
return AnalysisResult(
|
||||
{
|
||||
@@ -222,6 +239,22 @@ class RecipeAnalysisService:
|
||||
|
||||
# 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(
|
||||
self,
|
||||
metadata: dict[str, Any],
|
||||
@@ -234,7 +267,12 @@ class RecipeAnalysisService:
|
||||
) -> AnalysisResult:
|
||||
parser = self._recipe_parser_factory.create_parser(metadata)
|
||||
if parser is None:
|
||||
payload = {"error": "No parser found for this image", "loras": []}
|
||||
# Provide more specific error message based on metadata source
|
||||
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:
|
||||
payload["image_base64"] = self._encode_file(image_path)
|
||||
payload["is_video"] = is_video
|
||||
@@ -257,7 +295,9 @@ class RecipeAnalysisService:
|
||||
|
||||
matching_recipes: list[str] = []
|
||||
if fingerprint:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(fingerprint)
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||
fingerprint
|
||||
)
|
||||
result["matching_recipes"] = matching_recipes
|
||||
|
||||
return AnalysisResult(result)
|
||||
@@ -269,7 +309,10 @@ class RecipeAnalysisService:
|
||||
raise RecipeDownloadError(f"Failed to download image from URL: {result}")
|
||||
|
||||
def _metadata_not_found_response(self, path: str) -> AnalysisResult:
|
||||
payload: dict[str, Any] = {"error": "No metadata found in this image", "loras": []}
|
||||
payload: dict[str, Any] = {
|
||||
"error": "No metadata found in this image",
|
||||
"loras": [],
|
||||
}
|
||||
if os.path.exists(path):
|
||||
payload["image_base64"] = self._encode_file(path)
|
||||
return AnalysisResult(payload)
|
||||
@@ -305,7 +348,9 @@ class RecipeAnalysisService:
|
||||
|
||||
if hasattr(tensor_image, "shape"):
|
||||
self._logger.debug(
|
||||
"Tensor shape: %s, dtype: %s", tensor_image.shape, getattr(tensor_image, "dtype", None)
|
||||
"Tensor shape: %s, dtype: %s",
|
||||
tensor_image.shape,
|
||||
getattr(tensor_image, "dtype", None),
|
||||
)
|
||||
|
||||
import torch # type: ignore[import-not-found]
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
|
||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
|
||||
from ..utils.tag_priorities import (
|
||||
PriorityTagEntry,
|
||||
@@ -59,6 +60,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"optimize_example_images": True,
|
||||
"auto_download_example_images": False,
|
||||
"blur_mature_content": True,
|
||||
"mature_blur_level": "R",
|
||||
"autoplay_on_hover": False,
|
||||
"display_density": "default",
|
||||
"card_info_display": "always",
|
||||
@@ -274,6 +276,16 @@ class SettingsManager:
|
||||
self.settings["metadata_refresh_skip_paths"] = []
|
||||
inserted_defaults = True
|
||||
|
||||
had_mature_level = "mature_blur_level" in self.settings
|
||||
raw_mature_level = self.settings.get("mature_blur_level")
|
||||
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
|
||||
if normalized_mature_level != raw_mature_level:
|
||||
self.settings["mature_blur_level"] = normalized_mature_level
|
||||
if had_mature_level:
|
||||
updated_existing = True
|
||||
else:
|
||||
inserted_defaults = True
|
||||
|
||||
for key, value in defaults.items():
|
||||
if key == "priority_tags":
|
||||
continue
|
||||
@@ -608,6 +620,7 @@ class SettingsManager:
|
||||
'optimizeExampleImages': 'optimize_example_images',
|
||||
'autoDownloadExampleImages': 'auto_download_example_images',
|
||||
'blurMatureContent': 'blur_mature_content',
|
||||
'matureBlurLevel': 'mature_blur_level',
|
||||
'autoplayOnHover': 'autoplay_on_hover',
|
||||
'displayDensity': 'display_density',
|
||||
'cardInfoDisplay': 'card_info_display',
|
||||
@@ -860,6 +873,13 @@ class SettingsManager:
|
||||
|
||||
return normalized
|
||||
|
||||
def normalize_mature_blur_level(self, value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().upper()
|
||||
if normalized in VALID_MATURE_BLUR_LEVELS:
|
||||
return normalized
|
||||
return "R"
|
||||
|
||||
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
@@ -1012,6 +1032,8 @@ class SettingsManager:
|
||||
value = self.normalize_auto_organize_exclusions(value)
|
||||
elif key == "metadata_refresh_skip_paths":
|
||||
value = self.normalize_metadata_refresh_skip_paths(value)
|
||||
elif key == "mature_blur_level":
|
||||
value = self.normalize_mature_blur_level(value)
|
||||
self.settings[key] = value
|
||||
portable_switch_pending = False
|
||||
if key == "use_portable_settings" and isinstance(value, bool):
|
||||
|
||||
@@ -69,7 +69,9 @@ class TagFTSIndex:
|
||||
_DEFAULT_FILENAME = "tag_fts.sqlite"
|
||||
_CSV_FILENAME = "danbooru_e621_merged.csv"
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None, csv_path: Optional[str] = None) -> None:
|
||||
def __init__(
|
||||
self, db_path: Optional[str] = None, csv_path: Optional[str] = None
|
||||
) -> None:
|
||||
"""Initialize the FTS index.
|
||||
|
||||
Args:
|
||||
@@ -92,7 +94,9 @@ class TagFTSIndex:
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not create FTS index directory %s: %s", directory, exc)
|
||||
logger.warning(
|
||||
"Could not create FTS index directory %s: %s", directory, exc
|
||||
)
|
||||
|
||||
def _resolve_default_db_path(self) -> str:
|
||||
"""Resolve the default database path."""
|
||||
@@ -173,13 +177,15 @@ class TagFTSIndex:
|
||||
# Set schema version
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
self._schema_initialized = True
|
||||
self._needs_rebuild = needs_rebuild
|
||||
logger.debug("Tag FTS index schema initialized at %s", self._db_path)
|
||||
logger.debug(
|
||||
"Tag FTS index schema initialized at %s", self._db_path
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
@@ -206,13 +212,20 @@ class TagFTSIndex:
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
# Old schema without version, needs rebuild
|
||||
logger.info("Migrating tag FTS index to schema version %d (adding alias support)", SCHEMA_VERSION)
|
||||
logger.info(
|
||||
"Migrating tag FTS index to schema version %d (adding alias support)",
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
self._drop_old_tables(conn)
|
||||
return True
|
||||
|
||||
current_version = int(row[0])
|
||||
if current_version < SCHEMA_VERSION:
|
||||
logger.info("Migrating tag FTS index from version %d to %d", current_version, SCHEMA_VERSION)
|
||||
logger.info(
|
||||
"Migrating tag FTS index from version %d to %d",
|
||||
current_version,
|
||||
SCHEMA_VERSION,
|
||||
)
|
||||
self._drop_old_tables(conn)
|
||||
return True
|
||||
|
||||
@@ -246,7 +259,9 @@ class TagFTSIndex:
|
||||
return
|
||||
|
||||
if not os.path.exists(self._csv_path):
|
||||
logger.warning("CSV file not found at %s, cannot build tag index", self._csv_path)
|
||||
logger.warning(
|
||||
"CSV file not found at %s, cannot build tag index", self._csv_path
|
||||
)
|
||||
return
|
||||
|
||||
self._indexing_in_progress = True
|
||||
@@ -314,22 +329,24 @@ class TagFTSIndex:
|
||||
# Update metadata
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("last_build_time", str(time.time()))
|
||||
("last_build_time", str(time.time())),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("tag_count", str(total_inserted))
|
||||
("tag_count", str(total_inserted)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
("schema_version", str(SCHEMA_VERSION))
|
||||
("schema_version", str(SCHEMA_VERSION)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
"Tag FTS index built: %d tags indexed (%d with aliases) in %.2fs",
|
||||
total_inserted, tags_with_aliases, elapsed
|
||||
total_inserted,
|
||||
tags_with_aliases,
|
||||
elapsed,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -350,7 +367,7 @@ class TagFTSIndex:
|
||||
# Insert into tags table (with aliases)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO tags (tag_name, category, post_count, aliases) VALUES (?, ?, ?, ?)",
|
||||
rows
|
||||
rows,
|
||||
)
|
||||
|
||||
# Build a map of tag_name -> aliases for FTS insertion
|
||||
@@ -362,7 +379,7 @@ class TagFTSIndex:
|
||||
placeholders = ",".join("?" * len(tag_names))
|
||||
cursor = conn.execute(
|
||||
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 ...")
|
||||
@@ -379,13 +396,17 @@ class TagFTSIndex:
|
||||
alias = alias[1:] # Remove leading slash
|
||||
if alias:
|
||||
alias_parts.append(alias)
|
||||
searchable_text = f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
|
||||
searchable_text = (
|
||||
f"{tag_name} {' '.join(alias_parts)}" if alias_parts else tag_name
|
||||
)
|
||||
else:
|
||||
searchable_text = tag_name
|
||||
fts_rows.append((rowid, searchable_text))
|
||||
|
||||
if fts_rows:
|
||||
conn.executemany("INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows)
|
||||
conn.executemany(
|
||||
"INSERT INTO tag_fts (rowid, searchable_text) VALUES (?, ?)", fts_rows
|
||||
)
|
||||
|
||||
def ensure_ready(self) -> bool:
|
||||
"""Ensure the index is ready, building if necessary.
|
||||
@@ -420,21 +441,28 @@ class TagFTSIndex:
|
||||
self,
|
||||
query: str,
|
||||
categories: Optional[List[int]] = None,
|
||||
limit: int = 20
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[Dict]:
|
||||
"""Search tags using FTS5 with prefix matching.
|
||||
|
||||
Supports alias search: if the query matches an alias rather than
|
||||
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:
|
||||
query: The search query string.
|
||||
categories: Optional list of category IDs to filter by.
|
||||
limit: Maximum number of results to return.
|
||||
offset: Number of results to skip.
|
||||
|
||||
Returns:
|
||||
List of dictionaries with tag_name, category, post_count,
|
||||
and optionally matched_alias.
|
||||
rank_score, and optionally matched_alias.
|
||||
"""
|
||||
# Ensure index is ready (lazy initialization)
|
||||
if not self.ensure_ready():
|
||||
@@ -450,35 +478,67 @@ class TagFTSIndex:
|
||||
if not fts_query:
|
||||
return []
|
||||
|
||||
query_lower = query.lower().strip()
|
||||
|
||||
try:
|
||||
with self._lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
# Build the SQL query - now also fetch aliases for matched_alias detection
|
||||
# Use subquery for category filter to ensure FTS is evaluated first
|
||||
# Build the SQL query with bm25 ranking
|
||||
# FTS5 bm25() returns negative scores, lower is better
|
||||
# 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:
|
||||
placeholders = ",".join("?" * len(categories))
|
||||
sql = f"""
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
||||
FROM tags t
|
||||
WHERE t.rowid IN (
|
||||
SELECT rowid FROM tag_fts WHERE searchable_text MATCH ?
|
||||
)
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
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})
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [fts_query] + categories + [limit]
|
||||
# Escape special LIKE characters and add wildcard
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
params = (
|
||||
[query_escaped + "%", fts_query]
|
||||
+ categories
|
||||
+ [limit, offset]
|
||||
)
|
||||
else:
|
||||
sql = """
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases
|
||||
FROM tag_fts f
|
||||
JOIN tags t ON f.rowid = t.rowid
|
||||
WHERE f.searchable_text MATCH ?
|
||||
ORDER BY t.post_count DESC
|
||||
LIMIT ?
|
||||
SELECT t.tag_name, t.category, t.post_count, t.aliases,
|
||||
CASE
|
||||
WHEN t.tag_name LIKE ? ESCAPE '\\' THEN 1
|
||||
ELSE 0
|
||||
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 ?
|
||||
ORDER BY is_tag_name_match DESC, rank_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
params = [fts_query, limit]
|
||||
query_escaped = (
|
||||
query_lower.lstrip("/")
|
||||
.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
params = [query_escaped + "%", fts_query, limit, offset]
|
||||
|
||||
cursor = conn.execute(sql, params)
|
||||
results = []
|
||||
@@ -487,8 +547,17 @@ class TagFTSIndex:
|
||||
"tag_name": row[0],
|
||||
"category": row[1],
|
||||
"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
|
||||
matched_alias = self._find_matched_alias(query, row[0], row[3])
|
||||
if matched_alias:
|
||||
@@ -502,7 +571,9 @@ class TagFTSIndex:
|
||||
logger.debug("Tag FTS search error for query '%s': %s", query, exc)
|
||||
return []
|
||||
|
||||
def _find_matched_alias(self, query: str, tag_name: str, aliases_str: str) -> Optional[str]:
|
||||
def _find_matched_alias(
|
||||
self, query: str, tag_name: str, aliases_str: str
|
||||
) -> Optional[str]:
|
||||
"""Find which alias matched the query, if any.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -40,49 +40,39 @@ async def calculate_sha256(file_path: str) -> str:
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
def find_preview_file(base_name: str, dir_path: str) -> str:
|
||||
"""Find preview file for given base name in directory"""
|
||||
|
||||
"""Find preview file for given base name in directory.
|
||||
|
||||
Performs an exact-case check first (fast path), then falls back to a
|
||||
case-insensitive scan so that files like ``model.WEBP`` or ``model.Png``
|
||||
are discovered on case-sensitive filesystems.
|
||||
"""
|
||||
|
||||
temp_extensions = PREVIEW_EXTENSIONS.copy()
|
||||
# Add example extension for compatibility
|
||||
# https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225
|
||||
# The preview image will be optimized to lora-name.webp, so it won't affect other logic
|
||||
temp_extensions.append(".example.0.jpeg")
|
||||
|
||||
# Fast path: exact-case match
|
||||
for ext in temp_extensions:
|
||||
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
|
||||
if os.path.exists(full_pattern):
|
||||
# Check if this is an image and not already webp
|
||||
# TODO: disable the optimization for now, maybe add a config option later
|
||||
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
|
||||
# try:
|
||||
# # Optimize the image to webp format
|
||||
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
|
||||
|
||||
# # Use ExifUtils to optimize the image
|
||||
# with open(full_pattern, 'rb') as f:
|
||||
# image_data = f.read()
|
||||
|
||||
# optimized_data, _ = ExifUtils.optimize_image(
|
||||
# image_data=image_data,
|
||||
# target_width=CARD_PREVIEW_WIDTH,
|
||||
# format='webp',
|
||||
# quality=85,
|
||||
# preserve_metadata=False
|
||||
# )
|
||||
|
||||
# # Save the optimized webp file
|
||||
# with open(webp_path, 'wb') as f:
|
||||
# f.write(optimized_data)
|
||||
|
||||
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
|
||||
# return webp_path.replace(os.sep, "/")
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
|
||||
# # Fall back to original file if optimization fails
|
||||
# return full_pattern.replace(os.sep, "/")
|
||||
|
||||
# Return the original path for webp images or non-image files
|
||||
return full_pattern.replace(os.sep, "/")
|
||||
|
||||
|
||||
# Slow path: case-insensitive match for systems with mixed-case extensions
|
||||
# (e.g. .WEBP, .Png, .JPG placed manually or by external tools)
|
||||
try:
|
||||
dir_entries = os.listdir(dir_path)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
base_lower = base_name.lower()
|
||||
for ext in temp_extensions:
|
||||
target = f"{base_lower}{ext}" # ext is already lowercase
|
||||
for entry in dir_entries:
|
||||
if entry.lower() == target:
|
||||
return os.path.join(dir_path, entry).replace(os.sep, "/")
|
||||
|
||||
return ""
|
||||
|
||||
def get_preview_extension(preview_path: str) -> str:
|
||||
|
||||
@@ -4,32 +4,40 @@ from datetime import datetime
|
||||
import os
|
||||
from .model_utils import determine_base_model
|
||||
|
||||
|
||||
@dataclass
|
||||
class BaseModelMetadata:
|
||||
"""Base class for all model metadata structures"""
|
||||
file_name: str # The filename without extension
|
||||
model_name: str # The model's name defined by the creator
|
||||
file_path: str # Full path to the model file
|
||||
size: int # File size in bytes
|
||||
modified: float # Timestamp when the model was added to the management system
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether from Civitai
|
||||
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
|
||||
file_name: str # The filename without extension
|
||||
model_name: str # The model's name defined by the creator
|
||||
file_path: str # Full path to the model file
|
||||
size: int # File size in bytes
|
||||
modified: float # Timestamp when the model was added to the management system
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether from Civitai
|
||||
civitai: Dict[str, Any] = field(
|
||||
default_factory=dict
|
||||
) # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
modelDescription: str = "" # Full model description
|
||||
civitai_deleted: bool = False # Whether deleted from Civitai
|
||||
favorite: bool = False # Whether the model is a favorite
|
||||
exclude: bool = False # Whether to exclude this model from the cache
|
||||
db_checked: bool = False # Whether checked in archive DB
|
||||
skip_metadata_refresh: bool = False # Whether to skip this model during bulk metadata refresh
|
||||
favorite: bool = False # Whether the model is a favorite
|
||||
exclude: bool = False # Whether to exclude this model from the cache
|
||||
db_checked: bool = False # Whether checked in archive DB
|
||||
skip_metadata_refresh: bool = (
|
||||
False # Whether to skip this model during bulk metadata refresh
|
||||
)
|
||||
metadata_source: Optional[str] = None # Last provider that supplied metadata
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
||||
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
|
||||
_unknown_fields: Dict[str, Any] = field(
|
||||
default_factory=dict, repr=False, compare=False
|
||||
) # Store unknown fields
|
||||
|
||||
def __post_init__(self):
|
||||
# Initialize empty lists to avoid mutable default parameter issue
|
||||
@@ -40,211 +48,238 @@ class BaseModelMetadata:
|
||||
self.tags = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> 'BaseModelMetadata':
|
||||
def from_dict(cls, data: Dict) -> "BaseModelMetadata":
|
||||
"""Create instance from dictionary"""
|
||||
data_copy = data.copy()
|
||||
|
||||
|
||||
# Use cached fields if available, otherwise compute them
|
||||
if not hasattr(cls, '_known_fields_cache'):
|
||||
if not hasattr(cls, "_known_fields_cache"):
|
||||
known_fields = set()
|
||||
for c in cls.mro():
|
||||
if hasattr(c, '__annotations__'):
|
||||
if hasattr(c, "__annotations__"):
|
||||
known_fields.update(c.__annotations__.keys())
|
||||
cls._known_fields_cache = known_fields
|
||||
|
||||
|
||||
known_fields = cls._known_fields_cache
|
||||
|
||||
|
||||
# Extract fields that match our class attributes
|
||||
fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields}
|
||||
|
||||
|
||||
# Store unknown fields separately
|
||||
unknown_fields = {k: v for k, v in data_copy.items() if k not in known_fields and not k.startswith('_')}
|
||||
|
||||
unknown_fields = {
|
||||
k: v
|
||||
for k, v in data_copy.items()
|
||||
if k not in known_fields and not k.startswith("_")
|
||||
}
|
||||
|
||||
# Create instance with known fields
|
||||
instance = cls(**fields_to_use)
|
||||
|
||||
|
||||
# Add unknown fields as a separate attribute
|
||||
instance._unknown_fields = unknown_fields
|
||||
|
||||
|
||||
return instance
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for JSON serialization"""
|
||||
result = asdict(self)
|
||||
|
||||
|
||||
# Remove private fields
|
||||
result = {k: v for k, v in result.items() if not k.startswith('_')}
|
||||
|
||||
result = {k: v for k, v in result.items() if not k.startswith("_")}
|
||||
|
||||
# Add back unknown fields if they exist
|
||||
if hasattr(self, '_unknown_fields'):
|
||||
if hasattr(self, "_unknown_fields"):
|
||||
result.update(self._unknown_fields)
|
||||
|
||||
|
||||
return result
|
||||
|
||||
def update_civitai_info(self, civitai_data: Dict) -> None:
|
||||
"""Update Civitai information"""
|
||||
self.civitai = civitai_data
|
||||
|
||||
def update_file_info(self, file_path: str) -> None:
|
||||
"""Update metadata with actual file information"""
|
||||
def update_file_info(self, file_path: str, update_timestamps: bool = False) -> None:
|
||||
"""
|
||||
Update metadata with actual file information.
|
||||
|
||||
Args:
|
||||
file_path: Path to the model file
|
||||
update_timestamps: If True, update size and modified from filesystem.
|
||||
If False (default), only update file_path and file_name.
|
||||
Set to True only when file has been moved/relocated.
|
||||
"""
|
||||
if os.path.exists(file_path):
|
||||
self.size = os.path.getsize(file_path)
|
||||
self.modified = os.path.getmtime(file_path)
|
||||
self.file_path = file_path.replace(os.sep, '/')
|
||||
# Update file_name when file_path changes
|
||||
if update_timestamps:
|
||||
# Only update size and modified when file has been relocated
|
||||
self.size = os.path.getsize(file_path)
|
||||
self.modified = os.path.getmtime(file_path)
|
||||
# Always update paths when this method is called
|
||||
self.file_path = file_path.replace(os.sep, "/")
|
||||
self.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_filename(target_dir: str, base_name: str, extension: str, hash_provider: callable = None) -> str:
|
||||
def generate_unique_filename(
|
||||
target_dir: str, base_name: str, extension: str, hash_provider: callable = None
|
||||
) -> str:
|
||||
"""Generate a unique filename to avoid conflicts
|
||||
|
||||
|
||||
Args:
|
||||
target_dir: Target directory path
|
||||
base_name: Base filename without extension
|
||||
extension: File extension including the dot
|
||||
hash_provider: A callable that returns the SHA256 hash when needed
|
||||
|
||||
|
||||
Returns:
|
||||
str: Unique filename that doesn't conflict with existing files
|
||||
"""
|
||||
original_filename = f"{base_name}{extension}"
|
||||
target_path = os.path.join(target_dir, original_filename)
|
||||
|
||||
|
||||
# If no conflict, return original filename
|
||||
if not os.path.exists(target_path):
|
||||
return original_filename
|
||||
|
||||
|
||||
# Only compute hash when needed
|
||||
if hash_provider:
|
||||
sha256_hash = hash_provider()
|
||||
else:
|
||||
sha256_hash = "0000"
|
||||
|
||||
|
||||
# Generate short hash (first 4 characters of SHA256)
|
||||
short_hash = sha256_hash[:4] if sha256_hash else "0000"
|
||||
|
||||
|
||||
# Try with short hash suffix
|
||||
unique_filename = f"{base_name}-{short_hash}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
|
||||
|
||||
# If still conflicts, add incremental number
|
||||
counter = 1
|
||||
while os.path.exists(unique_path):
|
||||
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
|
||||
unique_path = os.path.join(target_dir, unique_filename)
|
||||
counter += 1
|
||||
|
||||
|
||||
return unique_filename
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoraMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for a Lora model"""
|
||||
usage_tips: str = "{}" # Usage tips for the model, json string
|
||||
|
||||
usage_tips: str = "{}" # Usage tips for the model, json string
|
||||
|
||||
@classmethod
|
||||
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'LoraMetadata':
|
||||
def from_civitai_info(
|
||||
cls, version_info: Dict, file_info: Dict, save_path: str
|
||||
) -> "LoraMetadata":
|
||||
"""Create LoraMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get('name', '')
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
file_name = file_info.get("name", "")
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
|
||||
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
|
||||
base_model=base_model,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_nsfw_level=0, # Will be updated after preview download
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0, # Will be updated after preview download
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
tags=tags,
|
||||
modelDescription=description
|
||||
modelDescription=description,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for a Checkpoint model"""
|
||||
|
||||
sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.)
|
||||
|
||||
@classmethod
|
||||
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'CheckpointMetadata':
|
||||
def from_civitai_info(
|
||||
cls, version_info: Dict, file_info: Dict, save_path: str
|
||||
) -> "CheckpointMetadata":
|
||||
"""Create CheckpointMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get('name', '')
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
sub_type = version_info.get('type', 'checkpoint')
|
||||
file_name = file_info.get("name", "")
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sub_type = version_info.get("type", "checkpoint")
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
|
||||
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
|
||||
base_model=base_model,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description
|
||||
modelDescription=description,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for an Embedding model"""
|
||||
|
||||
sub_type: str = "embedding"
|
||||
|
||||
@classmethod
|
||||
def from_civitai_info(cls, version_info: Dict, file_info: Dict, save_path: str) -> 'EmbeddingMetadata':
|
||||
def from_civitai_info(
|
||||
cls, version_info: Dict, file_info: Dict, save_path: str
|
||||
) -> "EmbeddingMetadata":
|
||||
"""Create EmbeddingMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get('name', '')
|
||||
base_model = determine_base_model(version_info.get('baseModel', ''))
|
||||
sub_type = version_info.get('type', 'embedding')
|
||||
file_name = file_info.get("name", "")
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sub_type = version_info.get("type", "embedding")
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get('model') or {}
|
||||
if 'tags' in model_data:
|
||||
tags = model_data['tags']
|
||||
if 'description' in model_data:
|
||||
description = model_data['description']
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get('name', os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, '/'),
|
||||
size=file_info.get('sizeKB', 0) * 1024,
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=(file_info.get('hashes') or {}).get('SHA256', '').lower(),
|
||||
sha256=(file_info.get("hashes") or {}).get("SHA256", "").lower(),
|
||||
base_model=base_model,
|
||||
preview_url='', # Will be updated after preview download
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description
|
||||
modelDescription=description,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping, Optional, Sequence, Tuple
|
||||
from typing import Any, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from .constants import NSFW_LEVELS
|
||||
|
||||
PreviewMedia = Mapping[str, object]
|
||||
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
|
||||
|
||||
|
||||
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
|
||||
"""Resolve the configured mature blur threshold from settings.
|
||||
|
||||
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
|
||||
missing value falls back to ``R``.
|
||||
"""
|
||||
|
||||
if not isinstance(settings, Mapping):
|
||||
return NSFW_LEVELS.get("R", 4)
|
||||
|
||||
raw_level = settings.get("mature_blur_level", "R")
|
||||
normalized = str(raw_level).strip().upper()
|
||||
if normalized not in VALID_MATURE_BLUR_LEVELS:
|
||||
normalized = "R"
|
||||
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
|
||||
|
||||
|
||||
def select_preview_media(
|
||||
images: Sequence[Mapping[str, object]] | None,
|
||||
*,
|
||||
blur_mature_content: bool,
|
||||
mature_threshold: int | None = None,
|
||||
) -> Tuple[Optional[PreviewMedia], int]:
|
||||
"""Select the most appropriate preview media entry.
|
||||
|
||||
When ``blur_mature_content`` is enabled we first try to return the first media
|
||||
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
|
||||
available we return the media entry with the lowest NSFW level. When the
|
||||
setting is disabled we simply return the first entry.
|
||||
item with an ``nsfwLevel`` lower than the configured mature threshold
|
||||
(defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
|
||||
the media entry with the lowest NSFW level. When the setting is disabled we
|
||||
simply return the first entry.
|
||||
"""
|
||||
|
||||
if not images:
|
||||
@@ -45,7 +65,9 @@ def select_preview_media(
|
||||
if not blur_mature_content:
|
||||
return selected, selected_level
|
||||
|
||||
safe_threshold = NSFW_LEVELS.get("R", 4)
|
||||
safe_threshold = (
|
||||
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
|
||||
)
|
||||
for candidate in candidates:
|
||||
level = _extract_nsfw_level(candidate)
|
||||
if level < safe_threshold:
|
||||
@@ -60,4 +82,4 @@ def select_preview_media(
|
||||
return selected, selected_level
|
||||
|
||||
|
||||
__all__ = ["select_preview_media"]
|
||||
__all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]
|
||||
|
||||
@@ -7,33 +7,47 @@ from ..config import config
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
import asyncio
|
||||
|
||||
|
||||
def get_lora_info(lora_name):
|
||||
"""Get the lora path and trigger words from cache"""
|
||||
|
||||
async def _get_lora_info_async():
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
file_path = item.get('file_path')
|
||||
if item.get("file_name") == lora_name:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
for root in config.loras_roots:
|
||||
root = root.replace(os.sep, '/')
|
||||
# Check all lora roots including extra paths
|
||||
all_roots = list(config.loras_roots or []) + list(
|
||||
config.extra_loras_roots or []
|
||||
)
|
||||
for root in all_roots:
|
||||
root = root.replace(os.sep, "/")
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(os.sep, '/')
|
||||
relative_path = os.path.relpath(file_path, root).replace(
|
||||
os.sep, "/"
|
||||
)
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get('civitai', {})
|
||||
trigger_words = civitai.get('trainedWords', []) if civitai else []
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = (
|
||||
civitai.get("trainedWords", []) if civitai else []
|
||||
)
|
||||
return relative_path, trigger_words
|
||||
# If not found in any root, return path with trigger words from cache
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
return lora_name, []
|
||||
|
||||
|
||||
try:
|
||||
# Check if we're already in an event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in a running loop, we need to use a different approach
|
||||
# Create a new thread to run the async code
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
@@ -41,11 +55,11 @@ def get_lora_info(lora_name):
|
||||
return new_loop.run_until_complete(_get_lora_info_async())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
|
||||
|
||||
except RuntimeError:
|
||||
# No event loop is running, we can use asyncio.run()
|
||||
return asyncio.run(_get_lora_info_async())
|
||||
@@ -53,33 +67,34 @@ def get_lora_info(lora_name):
|
||||
|
||||
def get_lora_info_absolute(lora_name):
|
||||
"""Get the absolute lora path and trigger words from cache
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (absolute_path, trigger_words) where absolute_path is the full
|
||||
tuple: (absolute_path, trigger_words) where absolute_path is the full
|
||||
file system path to the LoRA file, or original lora_name if not found
|
||||
"""
|
||||
|
||||
async def _get_lora_info_absolute_async():
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
file_path = item.get('file_path')
|
||||
if item.get("file_name") == lora_name:
|
||||
file_path = item.get("file_path")
|
||||
if file_path:
|
||||
# Return absolute path directly
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get('civitai', {})
|
||||
trigger_words = civitai.get('trainedWords', []) if civitai else []
|
||||
civitai = item.get("civitai", {})
|
||||
trigger_words = civitai.get("trainedWords", []) if civitai else []
|
||||
return file_path, trigger_words
|
||||
return lora_name, []
|
||||
|
||||
|
||||
try:
|
||||
# Check if we're already in an event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in a running loop, we need to use a different approach
|
||||
# Create a new thread to run the async code
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
@@ -87,50 +102,161 @@ def get_lora_info_absolute(lora_name):
|
||||
return new_loop.run_until_complete(_get_lora_info_absolute_async())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
|
||||
|
||||
except RuntimeError:
|
||||
# No event loop is running, we can use asyncio.run()
|
||||
return asyncio.run(_get_lora_info_absolute_async())
|
||||
|
||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||
"""
|
||||
Check if text matches pattern using fuzzy matching.
|
||||
Returns True if similarity ratio is above threshold.
|
||||
"""
|
||||
if not pattern or not text:
|
||||
return False
|
||||
|
||||
# Convert both to lowercase for case-insensitive matching
|
||||
text = text.lower()
|
||||
pattern = pattern.lower()
|
||||
|
||||
# Split pattern into words
|
||||
search_words = pattern.split()
|
||||
|
||||
# Check each word
|
||||
for word in search_words:
|
||||
# First check if word is a substring (faster)
|
||||
if word in text:
|
||||
|
||||
def get_checkpoint_info_absolute(checkpoint_name):
|
||||
"""Get the absolute checkpoint path and metadata from cache
|
||||
|
||||
Supports ComfyUI-style model names (e.g., "folder/model_name.ext")
|
||||
|
||||
Args:
|
||||
checkpoint_name: The model name, can be:
|
||||
- ComfyUI format: "folder/model_name.safetensors"
|
||||
- Simple name: "model_name"
|
||||
|
||||
Returns:
|
||||
tuple: (absolute_path, metadata) where absolute_path is the full
|
||||
file system path to the checkpoint file, or original checkpoint_name if not found,
|
||||
metadata is the full model metadata dict or None
|
||||
"""
|
||||
|
||||
async def _get_checkpoint_info_absolute_async():
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
# Get model roots for matching
|
||||
model_roots = scanner.get_model_roots()
|
||||
|
||||
# Normalize the checkpoint name
|
||||
normalized_name = checkpoint_name.replace(os.sep, "/")
|
||||
|
||||
for item in cache.raw_data:
|
||||
file_path = item.get("file_path", "")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
# If not found as substring, try fuzzy matching
|
||||
# Check if any part of the text matches this word
|
||||
found_match = False
|
||||
for text_part in text.split():
|
||||
ratio = SequenceMatcher(None, text_part, word).ratio()
|
||||
if ratio >= threshold:
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
return False
|
||||
|
||||
# All words found either as substrings or fuzzy matches
|
||||
return True
|
||||
|
||||
# Format the stored path as ComfyUI-style name
|
||||
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
|
||||
|
||||
# Match by formatted name (normalize separators for robust comparison)
|
||||
if formatted_name.replace(os.sep, "/") == normalized_name or formatted_name == checkpoint_name:
|
||||
return file_path, item
|
||||
|
||||
# Also try matching by basename only (for backward compatibility)
|
||||
file_name = item.get("file_name", "")
|
||||
if (
|
||||
file_name == checkpoint_name
|
||||
or file_name == os.path.splitext(normalized_name)[0]
|
||||
):
|
||||
return file_path, item
|
||||
|
||||
return checkpoint_name, None
|
||||
|
||||
try:
|
||||
# Check if we're already in an event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
# If we're in a running loop, we need to use a different approach
|
||||
# Create a new thread to run the async code
|
||||
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_checkpoint_info_absolute_async()
|
||||
)
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
|
||||
except RuntimeError:
|
||||
# No event loop is running, we can use asyncio.run()
|
||||
return asyncio.run(_get_checkpoint_info_absolute_async())
|
||||
|
||||
|
||||
def _format_model_name_for_comfyui(file_path: str, model_roots: list) -> str:
|
||||
"""Format file path to ComfyUI-style model name (relative path with extension)
|
||||
|
||||
Example: /path/to/checkpoints/Illustrious/model.safetensors -> Illustrious/model.safetensors
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to the model file
|
||||
model_roots: List of model root directories
|
||||
|
||||
Returns:
|
||||
ComfyUI-style model name with relative path and extension
|
||||
"""
|
||||
# Find the matching root and get relative path
|
||||
for root in model_roots:
|
||||
try:
|
||||
# Normalize paths for comparison
|
||||
norm_file = os.path.normcase(os.path.abspath(file_path))
|
||||
norm_root = os.path.normcase(os.path.abspath(root))
|
||||
|
||||
# Add trailing separator for prefix check
|
||||
if not norm_root.endswith(os.sep):
|
||||
norm_root += os.sep
|
||||
|
||||
if norm_file.startswith(norm_root):
|
||||
# Use os.path.relpath to get relative path with OS-native separator
|
||||
return os.path.relpath(file_path, root)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# If no root matches, just return the basename with extension
|
||||
return os.path.basename(file_path)
|
||||
|
||||
|
||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||
"""
|
||||
Check if text matches pattern using fuzzy matching.
|
||||
Returns True if similarity ratio is above threshold.
|
||||
"""
|
||||
if not pattern or not text:
|
||||
return False
|
||||
|
||||
# Convert both to lowercase for case-insensitive matching
|
||||
text = text.lower()
|
||||
pattern = pattern.lower()
|
||||
|
||||
# Split pattern into words
|
||||
search_words = pattern.split()
|
||||
|
||||
# Check each word
|
||||
for word in search_words:
|
||||
# First check if word is a substring (faster)
|
||||
if word in text:
|
||||
continue
|
||||
|
||||
# If not found as substring, try fuzzy matching
|
||||
# Check if any part of the text matches this word
|
||||
found_match = False
|
||||
for text_part in text.split():
|
||||
ratio = SequenceMatcher(None, text_part, word).ratio()
|
||||
if ratio >= threshold:
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
return False
|
||||
|
||||
# All words found either as substrings or fuzzy matches
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
"""Sanitize a folder name by removing or replacing invalid characters.
|
||||
@@ -156,10 +282,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
# Collapse repeated replacement characters to a single instance
|
||||
if replacement:
|
||||
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
|
||||
sanitized = sanitized.strip(replacement)
|
||||
|
||||
# Remove trailing spaces or periods which are invalid on Windows
|
||||
sanitized = sanitized.rstrip(" .")
|
||||
# Combine stripping to be idempotent:
|
||||
# Right side: strip replacement, space, and dot (Windows restriction)
|
||||
# Left side: strip replacement and space (leading dots are allowed)
|
||||
sanitized = sanitized.rstrip(" ." + replacement).lstrip(" " + replacement)
|
||||
else:
|
||||
# If no replacement, just strip spaces and dots from right, spaces from left
|
||||
sanitized = sanitized.rstrip(" .").lstrip(" ")
|
||||
|
||||
if not sanitized:
|
||||
return "unnamed"
|
||||
@@ -170,25 +299,25 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
|
||||
def calculate_recipe_fingerprint(loras):
|
||||
"""
|
||||
Calculate a unique fingerprint for a recipe based on its LoRAs.
|
||||
|
||||
|
||||
The fingerprint is created by sorting LoRA hashes, filtering invalid entries,
|
||||
normalizing strength values to 2 decimal places, and joining in format:
|
||||
hash1:strength1|hash2:strength2|...
|
||||
|
||||
|
||||
Args:
|
||||
loras (list): List of LoRA dictionaries with hash and strength values
|
||||
|
||||
|
||||
Returns:
|
||||
str: The calculated fingerprint
|
||||
"""
|
||||
if not loras:
|
||||
return ""
|
||||
|
||||
|
||||
valid_loras = []
|
||||
for lora in loras:
|
||||
if lora.get("exclude", False):
|
||||
continue
|
||||
|
||||
|
||||
hash_value = lora.get("hash", "")
|
||||
if isinstance(hash_value, str):
|
||||
hash_value = hash_value.lower()
|
||||
@@ -206,18 +335,23 @@ def calculate_recipe_fingerprint(loras):
|
||||
strength = round(float(strength_val), 2)
|
||||
except (ValueError, TypeError):
|
||||
strength = 1.0
|
||||
|
||||
|
||||
valid_loras.append((hash_value, strength))
|
||||
|
||||
|
||||
# Sort by hash
|
||||
valid_loras.sort()
|
||||
|
||||
|
||||
# Join in format hash1:strength1|hash2:strength2|...
|
||||
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
|
||||
|
||||
fingerprint = "|".join(
|
||||
[f"{hash_value}:{strength}" for hash_value, strength in valid_loras]
|
||||
)
|
||||
|
||||
return fingerprint
|
||||
|
||||
def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str:
|
||||
|
||||
def calculate_relative_path_for_model(
|
||||
model_data: Dict, model_type: str = "lora"
|
||||
) -> str:
|
||||
"""Calculate relative path for existing model using template from settings
|
||||
|
||||
Args:
|
||||
@@ -233,77 +367,80 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
|
||||
|
||||
# If template is empty, return empty path (flat structure)
|
||||
if not path_template:
|
||||
return ''
|
||||
return ""
|
||||
|
||||
# Get base model name from model metadata
|
||||
civitai_data = model_data.get('civitai', {})
|
||||
civitai_data = model_data.get("civitai", {})
|
||||
|
||||
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
|
||||
if civitai_data and civitai_data.get('id') is not None:
|
||||
base_model = model_data.get('base_model', '')
|
||||
if civitai_data and civitai_data.get("id") is not None:
|
||||
base_model = model_data.get("base_model", "")
|
||||
# Get author from civitai creator data
|
||||
creator_info = civitai_data.get('creator') or {}
|
||||
author = creator_info.get('username') or 'Anonymous'
|
||||
creator_info = civitai_data.get("creator") or {}
|
||||
author = creator_info.get("username") or "Anonymous"
|
||||
else:
|
||||
# Fallback to model_data fields for non-CivitAI models
|
||||
base_model = model_data.get('base_model', '')
|
||||
author = 'Anonymous' # Default for non-CivitAI models
|
||||
base_model = model_data.get("base_model", "")
|
||||
author = "Anonymous" # Default for non-CivitAI models
|
||||
|
||||
model_tags = model_data.get('tags', [])
|
||||
model_tags = model_data.get("tags", [])
|
||||
|
||||
# Apply mapping if available
|
||||
base_model_mappings = settings_manager.get('base_model_path_mappings', {})
|
||||
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
|
||||
mapped_base_model = base_model_mappings.get(base_model, base_model)
|
||||
|
||||
# Convert all tags to lowercase to avoid case sensitivity issues on Windows
|
||||
lowercase_tags = [tag.lower() for tag in model_tags if isinstance(tag, str)]
|
||||
first_tag = settings_manager.resolve_priority_tag_for_model(lowercase_tags, model_type)
|
||||
first_tag = settings_manager.resolve_priority_tag_for_model(
|
||||
lowercase_tags, model_type
|
||||
)
|
||||
|
||||
if not first_tag:
|
||||
first_tag = 'no tags' # Default if no tags available
|
||||
first_tag = "no tags" # Default if no tags available
|
||||
|
||||
# Format the template with available data
|
||||
model_name = sanitize_folder_name(model_data.get('model_name', ''))
|
||||
version_name = ''
|
||||
model_name = sanitize_folder_name(model_data.get("model_name", ""))
|
||||
version_name = ""
|
||||
|
||||
if isinstance(civitai_data, dict):
|
||||
version_name = sanitize_folder_name(civitai_data.get('name') or '')
|
||||
version_name = sanitize_folder_name(civitai_data.get("name") or "")
|
||||
|
||||
formatted_path = path_template
|
||||
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
|
||||
formatted_path = formatted_path.replace('{first_tag}', first_tag)
|
||||
formatted_path = formatted_path.replace('{author}', author)
|
||||
formatted_path = formatted_path.replace('{model_name}', model_name)
|
||||
formatted_path = formatted_path.replace('{version_name}', version_name)
|
||||
formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
|
||||
formatted_path = formatted_path.replace("{first_tag}", first_tag)
|
||||
formatted_path = formatted_path.replace("{author}", author)
|
||||
formatted_path = formatted_path.replace("{model_name}", model_name)
|
||||
formatted_path = formatted_path.replace("{version_name}", version_name)
|
||||
|
||||
if model_type == 'embedding':
|
||||
formatted_path = formatted_path.replace(' ', '_')
|
||||
if model_type == "embedding":
|
||||
formatted_path = formatted_path.replace(" ", "_")
|
||||
|
||||
return formatted_path
|
||||
|
||||
|
||||
def remove_empty_dirs(path):
|
||||
"""Recursively remove empty directories starting from the given path.
|
||||
|
||||
|
||||
Args:
|
||||
path (str): Root directory to start cleaning from
|
||||
|
||||
|
||||
Returns:
|
||||
int: Number of empty directories removed
|
||||
"""
|
||||
removed_count = 0
|
||||
|
||||
|
||||
if not os.path.isdir(path):
|
||||
return removed_count
|
||||
|
||||
|
||||
# List all files in directory
|
||||
files = os.listdir(path)
|
||||
|
||||
|
||||
# Process all subdirectories first
|
||||
for file in files:
|
||||
full_path = os.path.join(path, file)
|
||||
if os.path.isdir(full_path):
|
||||
removed_count += remove_empty_dirs(full_path)
|
||||
|
||||
|
||||
# Check if directory is now empty (after processing subdirectories)
|
||||
if not os.listdir(path):
|
||||
try:
|
||||
@@ -311,5 +448,5 @@ def remove_empty_dirs(path):
|
||||
removed_count += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
return removed_count
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
addopts = -v --import-mode=importlib -m "not performance"
|
||||
addopts = -v --import-mode=importlib -m "not performance" --ignore=__init__.py
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
|
||||
@@ -345,6 +345,7 @@ class StandaloneLoraManager(LoraManager):
|
||||
"/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/batch-import-progress", ws_manager.handle_connection)
|
||||
|
||||
# Schedule service initialization
|
||||
app.on_startup.append(lambda app: cls._initialize_services())
|
||||
|
||||
677
static/css/components/batch-import-modal.css
Normal file
677
static/css/components/batch-import-modal.css
Normal file
@@ -0,0 +1,677 @@
|
||||
/* Batch Import Modal Styles */
|
||||
|
||||
/* Step Containers */
|
||||
.batch-import-step {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
/* Section Description */
|
||||
.section-description {
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* Hint Text */
|
||||
.input-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.input-hint i {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Textarea Styling */
|
||||
#batchUrlInput {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
font-size: 0.9em;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
#batchUrlInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||
}
|
||||
|
||||
/* Checkbox Group */
|
||||
.checkbox-group {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"]:checked + .checkmark {
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
|
||||
content: '\f00c';
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
color: var(--lora-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Batch Options */
|
||||
.batch-options {
|
||||
margin-top: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Input with Button */
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input-with-button button {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
padding: 8px 16px;
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.input-with-button button:hover {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments for input-with-button */
|
||||
[data-theme="dark"] .input-with-button button {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .input-with-button button:hover {
|
||||
background: oklch(from var(--lora-accent) calc(l - 0.1) c h);
|
||||
}
|
||||
|
||||
/* Directory Browser */
|
||||
.directory-browser {
|
||||
margin-top: var(--space-3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.back-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.current-path {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.browser-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.browser-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-label i {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.folder-list,
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.folder-item,
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.folder-item:hover,
|
||||
.file-item:hover {
|
||||
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.folder-item.selected,
|
||||
.file-item.selected {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.15);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.folder-item i {
|
||||
color: #fbbf24;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.file-item i {
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-size {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.browser-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
font-weight: 600;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .directory-browser {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .browser-header,
|
||||
[data-theme="dark"] .browser-footer {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .folder-item i {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
/* Progress Container */
|
||||
.batch-progress-container {
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
color: var(--lora-accent);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.status-icon i {
|
||||
animation: fa-spin 2s infinite linear;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-bar-container {
|
||||
height: 8px;
|
||||
background: var(--bg-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Progress Stats */
|
||||
.progress-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stat-item.success {
|
||||
border-left: 3px solid #00B87A;
|
||||
}
|
||||
|
||||
.stat-item.failed {
|
||||
border-left: 3px solid var(--lora-error);
|
||||
}
|
||||
|
||||
.stat-item.skipped {
|
||||
border-left: 3px solid var(--lora-warning);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Current Item */
|
||||
.current-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.current-item-label {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.current-item-name {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Results Container */
|
||||
.batch-results-container {
|
||||
padding: var(--space-3);
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.results-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.results-icon {
|
||||
font-size: 3em;
|
||||
color: #00B87A;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.results-icon.warning {
|
||||
color: var(--lora-warning);
|
||||
}
|
||||
|
||||
.results-icon.error {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Results Summary - Matches progress-stats styling */
|
||||
.results-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.result-card.success {
|
||||
border-left: 3px solid #00B87A;
|
||||
}
|
||||
|
||||
.result-card.failed {
|
||||
border-left: 3px solid var(--lora-error);
|
||||
}
|
||||
|
||||
.result-card.skipped {
|
||||
border-left: 3px solid var(--lora-warning);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 1.4em;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Results Details */
|
||||
.results-details {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.details-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
color: var(--lora-accent);
|
||||
font-weight: 500;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.details-toggle:hover {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.1);
|
||||
}
|
||||
|
||||
.details-toggle i {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.details-toggle.expanded i {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.details-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--space-2);
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Result Item in Details */
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item-status {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.result-item-status.success {
|
||||
background: oklch(from #00B87A l c h / 0.2);
|
||||
color: #00B87A;
|
||||
}
|
||||
|
||||
.result-item-status.failed {
|
||||
background: oklch(from var(--lora-error) l c h / 0.2);
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
.result-item-status.skipped {
|
||||
background: oklch(from var(--lora-warning) l c h / 0.2);
|
||||
color: var(--lora-warning);
|
||||
}
|
||||
|
||||
.result-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-item-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result-item-error {
|
||||
font-size: 0.8em;
|
||||
color: var(--lora-error);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.progress-stats,
|
||||
.results-summary {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.batch-progress-container,
|
||||
.batch-results-container {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark Theme Adjustments */
|
||||
[data-theme="dark"] .batch-progress-container,
|
||||
[data-theme="dark"] .batch-results-container {
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .stat-item,
|
||||
[data-theme="dark"] .result-card,
|
||||
[data-theme="dark"] .current-item,
|
||||
[data-theme="dark"] .details-list {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
/* Cancelled State */
|
||||
.batch-progress-container.cancelled .progress-bar {
|
||||
background: var(--lora-warning);
|
||||
}
|
||||
|
||||
.batch-progress-container.cancelled .status-icon {
|
||||
color: var(--lora-warning);
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.batch-progress-container.error .progress-bar {
|
||||
background: var(--lora-error);
|
||||
}
|
||||
|
||||
.batch-progress-container.error .status-icon {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Completed State */
|
||||
.batch-progress-container.completed .progress-bar {
|
||||
background: #00B87A;
|
||||
}
|
||||
|
||||
.batch-progress-container.completed .status-icon {
|
||||
color: #00B87A;
|
||||
}
|
||||
|
||||
.batch-progress-container.completed .status-icon i {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.batch-progress-container.completed .status-icon i::before {
|
||||
content: '\f00c';
|
||||
}
|
||||
@@ -687,7 +687,7 @@
|
||||
padding: 12px 16px;
|
||||
background: oklch(var(--lora-warning) / 0.1);
|
||||
border: 1px solid var(--lora-warning);
|
||||
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
z-index: var(--z-overlay);
|
||||
display: none;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,8 @@ body.modal-open {
|
||||
[data-theme="dark"] .changelog-section,
|
||||
[data-theme="dark"] .update-info,
|
||||
[data-theme="dark"] .info-item,
|
||||
[data-theme="dark"] .path-preview {
|
||||
[data-theme="dark"] .path-preview,
|
||||
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
@@ -349,3 +350,87 @@ button:disabled,
|
||||
margin-top: var(--space-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Bulk Download Missing LoRAs Modal */
|
||||
#bulkDownloadMissingLorasModal .modal-body {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .preview-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-1) 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .lora-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .lora-version {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-left: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#bulkDownloadMissingLorasModal .confirmation-note i {
|
||||
color: var(--lora-accent);
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ export class BaseModelApiClient {
|
||||
replaceModelPreview(filePath) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*,video/mp4';
|
||||
input.accept = 'image/*,image/webp,video/mp4';
|
||||
|
||||
input.onchange = async () => {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
|
||||
@@ -259,6 +259,26 @@ export async function resetAndReload(updateFolders = false) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync changes - quick refresh without rebuilding cache (similar to models page)
|
||||
*/
|
||||
export async function syncChanges() {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
||||
|
||||
// Simply reload the recipes without rebuilding cache
|
||||
await resetAndReload();
|
||||
|
||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error syncing recipes:', error);
|
||||
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,8 @@ import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
|
||||
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
export class BulkContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
|
||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||
|
||||
if (sendToWorkflowAppendItem) {
|
||||
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
|
||||
@@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
if (setContentRatingItem) {
|
||||
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
|
||||
}
|
||||
if (downloadMissingLorasItem) {
|
||||
// Only show for recipes page
|
||||
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
|
||||
@@ -117,7 +124,10 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
countSkipStatus(skipState) {
|
||||
let count = 0;
|
||||
for (const filePath of state.selectedModels) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
const isSkipped = card.dataset.skip_metadata_refresh === 'true';
|
||||
if (isSkipped === skipState) {
|
||||
@@ -175,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'delete-all':
|
||||
bulkManager.showBulkDeleteModal();
|
||||
break;
|
||||
case 'download-missing-loras':
|
||||
this.handleDownloadMissingLoras();
|
||||
break;
|
||||
case 'clear':
|
||||
bulkManager.clearSelection();
|
||||
break;
|
||||
@@ -182,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
console.warn(`Unknown bulk action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle downloading missing LoRAs for selected recipes
|
||||
*/
|
||||
async handleDownloadMissingLoras() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get selected recipes from the virtual scroller
|
||||
const selectedRecipes = [];
|
||||
state.selectedModels.forEach(filePath => {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${CSS.escape(filePath)}"]`);
|
||||
if (card && card.recipeData) {
|
||||
selectedRecipes.push(card.recipeData);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedRecipes.length === 0) {
|
||||
// Try to get recipes from virtual scroller state
|
||||
const items = state.virtualScroller?.items || [];
|
||||
items.forEach(recipe => {
|
||||
if (recipe.file_path && state.selectedModels.has(recipe.file_path)) {
|
||||
selectedRecipes.push(recipe);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedRecipes.length === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
|
||||
|
||||
class RecipeCard {
|
||||
constructor(recipe, clickHandler) {
|
||||
@@ -74,7 +74,8 @@ class RecipeCard {
|
||||
|
||||
// NSFW blur logic - similar to LoraCard
|
||||
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
@@ -201,8 +202,9 @@ class RecipeCard {
|
||||
this.recipe.favorite = isFavorite;
|
||||
|
||||
// Re-find star icon in case of re-render during fault
|
||||
const filePathForXpath = this.recipe.file_path.replace(/"/g, '"');
|
||||
const currentCard = card.ownerDocument.evaluate(
|
||||
`.//*[@data-filepath="${this.recipe.file_path}"]`,
|
||||
`.//*[@data-filepath="${filePathForXpath}"]`,
|
||||
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
|
||||
).singleNodeValue || card;
|
||||
|
||||
|
||||
@@ -1299,7 +1299,6 @@ class RecipeModal {
|
||||
|
||||
// New method to navigate to the LoRAs page
|
||||
navigateToLorasPage(specificLoraIndex = null) {
|
||||
debugger;
|
||||
// Close the current modal
|
||||
modalManager.closeModal('recipeModal');
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||
|
||||
export class SidebarManager {
|
||||
constructor() {
|
||||
@@ -1294,15 +1295,19 @@ export class SidebarManager {
|
||||
const isExpanded = this.expandedNodes.has(currentPath);
|
||||
const isSelected = this.selectedPath === currentPath;
|
||||
|
||||
const escapedPath = escapeAttribute(currentPath);
|
||||
const escapedFolderName = escapeHtml(folderName);
|
||||
const escapedTitle = escapeAttribute(folderName);
|
||||
|
||||
return `
|
||||
<div class="sidebar-tree-node" data-path="${currentPath}">
|
||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}">
|
||||
<div class="sidebar-tree-node" data-path="${escapedPath}">
|
||||
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
||||
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
|
||||
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</div>
|
||||
<i class="fas fa-folder sidebar-tree-folder-icon"></i>
|
||||
<div class="sidebar-tree-folder-name" title="${folderName}">${folderName}</div>
|
||||
<div class="sidebar-tree-folder-name" title="${escapedTitle}">${escapedFolderName}</div>
|
||||
</div>
|
||||
${hasChildren ? `
|
||||
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
|
||||
@@ -1342,12 +1347,15 @@ export class SidebarManager {
|
||||
const foldersHtml = this.foldersList.map(folder => {
|
||||
const displayName = folder === '' ? '/' : folder;
|
||||
const isSelected = this.selectedPath === folder;
|
||||
const escapedPath = escapeAttribute(folder);
|
||||
const escapedDisplayName = escapeHtml(displayName);
|
||||
const escapedTitle = escapeAttribute(displayName);
|
||||
|
||||
return `
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}">
|
||||
<div class="sidebar-node-content" data-path="${folder}">
|
||||
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
|
||||
<div class="sidebar-node-content" data-path="${escapedPath}">
|
||||
<i class="fas fa-folder sidebar-folder-icon"></i>
|
||||
<div class="sidebar-folder-name" title="${displayName}">${displayName}</div>
|
||||
<div class="sidebar-folder-name" title="${escapedTitle}">${escapedDisplayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1570,7 +1578,8 @@ export class SidebarManager {
|
||||
|
||||
// Add selection to current path
|
||||
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||
const selectedItem = folderTree.querySelector(`[data-path="${this.selectedPath}"]`);
|
||||
const escapedPathSelector = CSS.escape(this.selectedPath);
|
||||
const selectedItem = folderTree.querySelector(`[data-path="${escapedPathSelector}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
@@ -1581,7 +1590,8 @@ export class SidebarManager {
|
||||
});
|
||||
|
||||
if (this.selectedPath !== null && this.selectedPath !== undefined) {
|
||||
const selectedNode = folderTree.querySelector(`[data-path="${this.selectedPath}"] .sidebar-tree-node-content`);
|
||||
const escapedPathSelector = CSS.escape(this.selectedPath);
|
||||
const selectedNode = folderTree.querySelector(`[data-path="${escapedPathSelector}"] .sidebar-tree-node-content`);
|
||||
if (selectedNode) {
|
||||
selectedNode.classList.add('selected');
|
||||
this.expandPathParents(this.selectedPath);
|
||||
@@ -1655,7 +1665,7 @@ export class SidebarManager {
|
||||
const breadcrumbs = [`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path="">
|
||||
<i class="fas fa-home"></i> ${this.apiClient.apiConfig.config.displayName} root
|
||||
<i class="fas fa-home"></i> ${escapeHtml(this.apiClient.apiConfig.config.displayName)} root
|
||||
</span>
|
||||
</div>
|
||||
`];
|
||||
@@ -1675,8 +1685,8 @@ export class SidebarManager {
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${nextLevelFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${folder}">
|
||||
${folder}
|
||||
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(folder)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
@@ -1692,12 +1702,14 @@ export class SidebarManager {
|
||||
|
||||
// Get siblings for this level
|
||||
const siblings = this.getSiblingFolders(parts, index);
|
||||
const escapedCurrentPath = escapeAttribute(currentPath);
|
||||
const escapedPart = escapeHtml(part);
|
||||
|
||||
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
|
||||
breadcrumbs.push(`
|
||||
<div class="breadcrumb-dropdown">
|
||||
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
|
||||
${part}
|
||||
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${escapedCurrentPath}">
|
||||
${escapedPart}
|
||||
${siblings.length > 1 ? `
|
||||
<span class="breadcrumb-dropdown-indicator">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
@@ -1706,11 +1718,14 @@ export class SidebarManager {
|
||||
</span>
|
||||
${siblings.length > 1 ? `
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${siblings.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||
data-path="${currentPath.replace(part, folder)}">
|
||||
${folder}
|
||||
</div>`).join('')
|
||||
${siblings.map(folder => {
|
||||
const siblingPath = parts.slice(0, index).concat(folder).join('/');
|
||||
return `
|
||||
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
|
||||
data-path="${escapeAttribute(siblingPath)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`;
|
||||
}).join('')
|
||||
}
|
||||
</div>
|
||||
` : ''}
|
||||
@@ -1732,8 +1747,8 @@ export class SidebarManager {
|
||||
</span>
|
||||
<div class="breadcrumb-dropdown-menu">
|
||||
${childFolders.map(folder => `
|
||||
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}">
|
||||
${folder}
|
||||
<div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(currentPath + '/' + folder)}">
|
||||
${escapeHtml(folder)}
|
||||
</div>`).join('')
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.nsfwLevel = nsfwLevel;
|
||||
|
||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
}
|
||||
|
||||
@@ -846,8 +846,14 @@ function setupLoraSpecificFields(filePath) {
|
||||
|
||||
const currentPath = resolveFilePath();
|
||||
if (!currentPath) return;
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
|
||||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedCurrentPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(currentPath)
|
||||
: currentPath.replace(/["\\]/g, '\\$&');
|
||||
const escapedFilePath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedCurrentPath}"]`) ||
|
||||
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||
|
||||
if (key === 'strength_range') {
|
||||
|
||||
@@ -49,7 +49,10 @@ function formatPresetKey(key) {
|
||||
*/
|
||||
window.removePreset = async function(key) {
|
||||
const filePath = document.querySelector('#modelModal .modal-content .file-path').dataset.filepath;
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
delete currentPresets[key];
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
|
||||
|
||||
/**
|
||||
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
|
||||
}
|
||||
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
|
||||
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
let overlay = mediaWrapper.querySelector('.nsfw-overlay');
|
||||
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* MetadataPanel.js
|
||||
* Generates metadata panels for showcase media items
|
||||
*/
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
/**
|
||||
* Generate metadata panel HTML
|
||||
@@ -49,6 +50,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
prompt = escapeHtml(prompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
@@ -64,6 +66,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
}
|
||||
|
||||
if (negativePrompt) {
|
||||
negativePrompt = escapeHtml(negativePrompt);
|
||||
content += `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
@@ -80,4 +83,4 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
|
||||
content += '</div></div>';
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { modalManager } from '../../../managers/ModalManager.js';
|
||||
import { translate } from '../../../utils/i18nHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
|
||||
import {
|
||||
initLazyLoading,
|
||||
initNsfwBlurHandlers,
|
||||
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
|
||||
// Check if media should be blurred
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
|
||||
815
static/js/managers/BatchImportManager.js
Normal file
815
static/js/managers/BatchImportManager.js
Normal file
@@ -0,0 +1,815 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { WS_ENDPOINTS } from '../api/apiConfig.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
* Manager for batch importing recipes from multiple images
|
||||
*/
|
||||
export class BatchImportManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.inputMode = 'urls'; // 'urls' or 'directory'
|
||||
this.operationId = null;
|
||||
this.wsConnection = null;
|
||||
this.pollingInterval = null;
|
||||
this.progress = null;
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the batch import modal
|
||||
*/
|
||||
showModal() {
|
||||
if (!this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
this.resetState();
|
||||
modalManager.showModal('batchImportModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the manager
|
||||
*/
|
||||
initialize() {
|
||||
this.initialized = true;
|
||||
|
||||
// Add event listener for persisting "Skip images without metadata" choice
|
||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||
if (skipNoMetadata) {
|
||||
skipNoMetadata.addEventListener('change', (e) => {
|
||||
setStorageItem('batch_import_skip_no_metadata', e.target.checked);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all state to initial values
|
||||
*/
|
||||
resetState() {
|
||||
this.inputMode = 'urls';
|
||||
this.operationId = null;
|
||||
this.progress = null;
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
|
||||
// Reset UI
|
||||
this.showStep('batchInputStep');
|
||||
this.toggleInputMode('urls');
|
||||
|
||||
// Clear inputs
|
||||
const urlInput = document.getElementById('batchUrlInput');
|
||||
if (urlInput) urlInput.value = '';
|
||||
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
if (directoryInput) directoryInput.value = '';
|
||||
|
||||
const tagsInput = document.getElementById('batchTagsInput');
|
||||
if (tagsInput) tagsInput.value = '';
|
||||
|
||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||
if (skipNoMetadata) {
|
||||
// Load preference from storage, defaulting to true
|
||||
skipNoMetadata.checked = getStorageItem('batch_import_skip_no_metadata', true);
|
||||
}
|
||||
|
||||
const recursiveCheck = document.getElementById('batchRecursiveCheck');
|
||||
if (recursiveCheck) recursiveCheck.checked = true;
|
||||
|
||||
// Reset progress UI
|
||||
this.updateProgressUI({
|
||||
total: 0,
|
||||
completed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
progress_percent: 0,
|
||||
current_item: '',
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
// Reset results
|
||||
const detailsList = document.getElementById('batchDetailsList');
|
||||
if (detailsList) {
|
||||
detailsList.innerHTML = '';
|
||||
detailsList.style.display = 'none';
|
||||
}
|
||||
|
||||
const toggleIcon = document.getElementById('resultsToggleIcon');
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.remove('expanded');
|
||||
}
|
||||
|
||||
// Clean up any existing connections
|
||||
this.cleanupConnections();
|
||||
|
||||
// Focus on the URL input field for better UX
|
||||
setTimeout(() => {
|
||||
const urlInput = document.getElementById('batchUrlInput');
|
||||
if (urlInput) {
|
||||
urlInput.focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a specific step in the modal
|
||||
*/
|
||||
showStep(stepId) {
|
||||
document.querySelectorAll('.batch-import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
|
||||
const step = document.getElementById(stepId);
|
||||
if (step) {
|
||||
step.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between URL list and directory input modes
|
||||
*/
|
||||
toggleInputMode(mode) {
|
||||
this.inputMode = mode;
|
||||
|
||||
// Update toggle buttons
|
||||
document.querySelectorAll('.toggle-btn[data-mode]').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
|
||||
const activeBtn = document.querySelector(`.toggle-btn[data-mode="${mode}"]`);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('active');
|
||||
}
|
||||
|
||||
// Show/hide appropriate sections
|
||||
const urlSection = document.getElementById('urlListSection');
|
||||
const directorySection = document.getElementById('directorySection');
|
||||
|
||||
if (urlSection && directorySection) {
|
||||
if (mode === 'urls') {
|
||||
urlSection.style.display = 'block';
|
||||
directorySection.style.display = 'none';
|
||||
} else {
|
||||
urlSection.style.display = 'none';
|
||||
directorySection.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the batch import process
|
||||
*/
|
||||
async startImport() {
|
||||
const data = this.collectInputData();
|
||||
|
||||
if (!this.validateInput(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show progress step
|
||||
this.showStep('batchProgressStep');
|
||||
|
||||
// Start the import
|
||||
const response = await this.sendStartRequest(data);
|
||||
|
||||
if (response.success) {
|
||||
this.operationId = response.operation_id;
|
||||
this.isCancelled = false;
|
||||
|
||||
// Connect to WebSocket for real-time updates
|
||||
this.connectWebSocket();
|
||||
|
||||
// Start polling as fallback
|
||||
this.startPolling();
|
||||
} else {
|
||||
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting batch import:', error);
|
||||
showToast('toast.recipes.batchImportFailed', { message: error.message }, 'error');
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect input data from the form
|
||||
*/
|
||||
collectInputData() {
|
||||
const data = {
|
||||
mode: this.inputMode,
|
||||
tags: [],
|
||||
skip_no_metadata: false
|
||||
};
|
||||
|
||||
// Collect tags
|
||||
const tagsInput = document.getElementById('batchTagsInput');
|
||||
if (tagsInput && tagsInput.value.trim()) {
|
||||
data.tags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
|
||||
}
|
||||
|
||||
// Collect skip_no_metadata
|
||||
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
|
||||
if (skipNoMetadata) {
|
||||
data.skip_no_metadata = skipNoMetadata.checked;
|
||||
}
|
||||
|
||||
if (this.inputMode === 'urls') {
|
||||
const urlInput = document.getElementById('batchUrlInput');
|
||||
if (urlInput) {
|
||||
const urls = urlInput.value.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
// Convert to items format
|
||||
data.items = urls.map(url => ({
|
||||
source: url,
|
||||
type: this.detectUrlType(url)
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
if (directoryInput) {
|
||||
data.directory = directoryInput.value.trim();
|
||||
}
|
||||
|
||||
const recursiveCheck = document.getElementById('batchRecursiveCheck');
|
||||
if (recursiveCheck) {
|
||||
data.recursive = recursiveCheck.checked;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a URL is http or local path
|
||||
*/
|
||||
detectUrlType(url) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return 'url';
|
||||
}
|
||||
return 'local_path';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the input data
|
||||
*/
|
||||
validateInput(data) {
|
||||
if (data.mode === 'urls') {
|
||||
if (!data.items || data.items.length === 0) {
|
||||
showToast('toast.recipes.batchImportNoUrls', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!data.directory) {
|
||||
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the start batch import request
|
||||
*/
|
||||
async sendStartRequest(data) {
|
||||
const endpoint = data.mode === 'urls'
|
||||
? '/api/lm/recipes/batch-import/start'
|
||||
: '/api/lm/recipes/batch-import/directory';
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket for real-time progress updates
|
||||
*/
|
||||
connectWebSocket() {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws/batch-import-progress?id=${this.operationId}`;
|
||||
|
||||
this.wsConnection = new WebSocket(wsUrl);
|
||||
|
||||
this.wsConnection.onopen = () => {
|
||||
console.log('Connected to batch import progress WebSocket');
|
||||
};
|
||||
|
||||
this.wsConnection.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'batch_import_progress') {
|
||||
this.handleProgressUpdate(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.wsConnection.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
this.wsConnection.onclose = () => {
|
||||
console.log('WebSocket connection closed');
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for progress updates (fallback)
|
||||
*/
|
||||
startPolling() {
|
||||
this.pollingInterval = setInterval(async () => {
|
||||
if (!this.operationId || this.isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/recipes/batch-import/progress?operation_id=${this.operationId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.progress) {
|
||||
this.handleProgressUpdate(data.progress);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling progress:', error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle progress update from WebSocket or polling
|
||||
*/
|
||||
handleProgressUpdate(progress) {
|
||||
this.progress = progress;
|
||||
this.updateProgressUI(progress);
|
||||
|
||||
// Check if import is complete
|
||||
if (progress.status === 'completed' || progress.status === 'cancelled' ||
|
||||
(progress.total > 0 && progress.completed >= progress.total)) {
|
||||
this.importComplete(progress);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress UI
|
||||
*/
|
||||
updateProgressUI(progress) {
|
||||
// Update progress bar
|
||||
const progressBar = document.getElementById('batchProgressBar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${progress.progress_percent || 0}%`;
|
||||
}
|
||||
|
||||
// Update percentage
|
||||
const progressPercent = document.getElementById('batchProgressPercent');
|
||||
if (progressPercent) {
|
||||
progressPercent.textContent = `${Math.round(progress.progress_percent || 0)}%`;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const totalCount = document.getElementById('batchTotalCount');
|
||||
if (totalCount) totalCount.textContent = progress.total || 0;
|
||||
|
||||
const successCount = document.getElementById('batchSuccessCount');
|
||||
if (successCount) successCount.textContent = progress.success || 0;
|
||||
|
||||
const failedCount = document.getElementById('batchFailedCount');
|
||||
if (failedCount) failedCount.textContent = progress.failed || 0;
|
||||
|
||||
const skippedCount = document.getElementById('batchSkippedCount');
|
||||
if (skippedCount) skippedCount.textContent = progress.skipped || 0;
|
||||
|
||||
// Update current item
|
||||
const currentItem = document.getElementById('batchCurrentItem');
|
||||
if (currentItem) {
|
||||
currentItem.textContent = progress.current_item || '-';
|
||||
}
|
||||
|
||||
// Update status text
|
||||
const statusText = document.getElementById('batchStatusText');
|
||||
if (statusText) {
|
||||
if (progress.status === 'running') {
|
||||
statusText.textContent = translate('recipes.batchImport.importing', {}, 'Importing...');
|
||||
} else if (progress.status === 'completed') {
|
||||
statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
|
||||
} else if (progress.status === 'cancelled') {
|
||||
statusText.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
// Update container classes
|
||||
const progressContainer = document.querySelector('.batch-progress-container');
|
||||
if (progressContainer) {
|
||||
progressContainer.classList.remove('completed', 'cancelled', 'error');
|
||||
if (progress.status === 'completed') {
|
||||
progressContainer.classList.add('completed');
|
||||
} else if (progress.status === 'cancelled') {
|
||||
progressContainer.classList.add('cancelled');
|
||||
} else if (progress.failed > 0 && progress.failed === progress.total) {
|
||||
progressContainer.classList.add('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle import completion
|
||||
*/
|
||||
importComplete(progress) {
|
||||
this.cleanupConnections();
|
||||
this.results = progress;
|
||||
|
||||
// Refresh recipes list to show newly imported recipes
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
window.recipeManager.loadRecipes();
|
||||
}
|
||||
|
||||
// Show results step
|
||||
this.showStep('batchResultsStep');
|
||||
this.updateResultsUI(progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the results UI
|
||||
*/
|
||||
updateResultsUI(progress) {
|
||||
// Update summary cards
|
||||
const resultsTotal = document.getElementById('resultsTotal');
|
||||
if (resultsTotal) resultsTotal.textContent = progress.total || 0;
|
||||
|
||||
const resultsSuccess = document.getElementById('resultsSuccess');
|
||||
if (resultsSuccess) resultsSuccess.textContent = progress.success || 0;
|
||||
|
||||
const resultsFailed = document.getElementById('resultsFailed');
|
||||
if (resultsFailed) resultsFailed.textContent = progress.failed || 0;
|
||||
|
||||
const resultsSkipped = document.getElementById('resultsSkipped');
|
||||
if (resultsSkipped) resultsSkipped.textContent = progress.skipped || 0;
|
||||
|
||||
// Update header based on results
|
||||
const resultsHeader = document.getElementById('batchResultsHeader');
|
||||
if (resultsHeader) {
|
||||
const icon = resultsHeader.querySelector('.results-icon i');
|
||||
const title = resultsHeader.querySelector('.results-title');
|
||||
|
||||
if (this.isCancelled) {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-stop-circle';
|
||||
icon.parentElement.classList.add('warning');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
|
||||
} else if (progress.failed === 0 && progress.success > 0) {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-check-circle';
|
||||
icon.parentElement.classList.remove('warning', 'error');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
|
||||
} else if (progress.failed > 0 && progress.success === 0) {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-times-circle';
|
||||
icon.parentElement.classList.add('error');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.failed', {}, 'Import failed');
|
||||
} else {
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-exclamation-circle';
|
||||
icon.parentElement.classList.add('warning');
|
||||
}
|
||||
if (title) title.textContent = translate('recipes.batchImport.completedWithErrors', {}, 'Completed with errors');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the results details visibility
|
||||
*/
|
||||
toggleResultsDetails() {
|
||||
const detailsList = document.getElementById('batchDetailsList');
|
||||
const toggleIcon = document.getElementById('resultsToggleIcon');
|
||||
const toggle = document.querySelector('.details-toggle');
|
||||
|
||||
if (detailsList && toggleIcon) {
|
||||
if (detailsList.style.display === 'none') {
|
||||
detailsList.style.display = 'block';
|
||||
toggleIcon.classList.add('expanded');
|
||||
if (toggle) toggle.classList.add('expanded');
|
||||
|
||||
// Load details if not loaded
|
||||
if (detailsList.children.length === 0 && this.results && this.results.items) {
|
||||
this.loadResultsDetails(this.results.items);
|
||||
}
|
||||
} else {
|
||||
detailsList.style.display = 'none';
|
||||
toggleIcon.classList.remove('expanded');
|
||||
if (toggle) toggle.classList.remove('expanded');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load results details into the list
|
||||
*/
|
||||
loadResultsDetails(items) {
|
||||
const detailsList = document.getElementById('batchDetailsList');
|
||||
if (!detailsList) return;
|
||||
|
||||
detailsList.innerHTML = '';
|
||||
|
||||
items.forEach(item => {
|
||||
const resultItem = document.createElement('div');
|
||||
resultItem.className = 'result-item';
|
||||
|
||||
const statusClass = item.status === 'success' ? 'success' :
|
||||
item.status === 'failed' ? 'failed' : 'skipped';
|
||||
const statusIcon = item.status === 'success' ? 'check' :
|
||||
item.status === 'failed' ? 'times' : 'forward';
|
||||
|
||||
resultItem.innerHTML = `
|
||||
<div class="result-item-status ${statusClass}">
|
||||
<i class="fas fa-${statusIcon}"></i>
|
||||
</div>
|
||||
<div class="result-item-info">
|
||||
<div class="result-item-name">${this.escapeHtml(item.source || item.current_item || 'Unknown')}</div>
|
||||
${item.error_message ? `<div class="result-item-error">${this.escapeHtml(item.error_message)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
detailsList.appendChild(resultItem);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the current import
|
||||
*/
|
||||
async cancelImport() {
|
||||
if (!this.operationId) return;
|
||||
|
||||
this.isCancelled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ operation_id: this.operationId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('toast.recipes.batchImportCancelling', {}, 'info');
|
||||
} else {
|
||||
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling import:', error);
|
||||
showToast('toast.recipes.batchImportCancelFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal and reset state
|
||||
*/
|
||||
closeAndReset() {
|
||||
this.cleanupConnections();
|
||||
this.resetState();
|
||||
modalManager.closeModal('batchImportModal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new import (from results step)
|
||||
*/
|
||||
startNewImport() {
|
||||
this.resetState();
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle directory browser visibility
|
||||
*/
|
||||
toggleDirectoryBrowser() {
|
||||
const browser = document.getElementById('batchDirectoryBrowser');
|
||||
if (browser) {
|
||||
const isVisible = browser.style.display !== 'none';
|
||||
browser.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
// Load initial directory when opening
|
||||
const currentPath = document.getElementById('batchDirectoryInput').value;
|
||||
this.loadDirectory(currentPath || '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load directory contents
|
||||
*/
|
||||
async loadDirectory(path) {
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/browse-directory', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.renderDirectoryBrowser(data);
|
||||
} else {
|
||||
showToast('toast.recipes.batchImportBrowseFailed', { message: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading directory:', error);
|
||||
showToast('toast.recipes.batchImportBrowseFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render directory browser UI
|
||||
*/
|
||||
renderDirectoryBrowser(data) {
|
||||
const currentPathEl = document.getElementById('batchCurrentPath');
|
||||
const folderList = document.getElementById('batchFolderList');
|
||||
const fileList = document.getElementById('batchFileList');
|
||||
const directoryCount = document.getElementById('batchDirectoryCount');
|
||||
const imageCount = document.getElementById('batchImageCount');
|
||||
|
||||
if (currentPathEl) {
|
||||
currentPathEl.textContent = data.current_path;
|
||||
}
|
||||
|
||||
// Render folders
|
||||
if (folderList) {
|
||||
folderList.innerHTML = '';
|
||||
|
||||
// Add parent directory if available
|
||||
if (data.parent_path) {
|
||||
const parentItem = this.createFolderItem('..', data.parent_path, true);
|
||||
folderList.appendChild(parentItem);
|
||||
}
|
||||
|
||||
data.directories.forEach(dir => {
|
||||
folderList.appendChild(this.createFolderItem(dir.name, dir.path));
|
||||
});
|
||||
}
|
||||
|
||||
// Render files
|
||||
if (fileList) {
|
||||
fileList.innerHTML = '';
|
||||
data.image_files.forEach(file => {
|
||||
fileList.appendChild(this.createFileItem(file.name, file.path, file.size));
|
||||
});
|
||||
}
|
||||
|
||||
// Update stats
|
||||
if (directoryCount) {
|
||||
directoryCount.textContent = data.directory_count;
|
||||
}
|
||||
if (imageCount) {
|
||||
imageCount.textContent = data.image_count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create folder item element
|
||||
*/
|
||||
createFolderItem(name, path, isParent = false) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'folder-item';
|
||||
item.dataset.path = path;
|
||||
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-folder${isParent ? '' : ''}"></i>
|
||||
<span class="item-name">${this.escapeHtml(name)}</span>
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
if (isParent) {
|
||||
this.navigateToParentDirectory();
|
||||
} else {
|
||||
this.loadDirectory(path);
|
||||
}
|
||||
});
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create file item element
|
||||
*/
|
||||
createFileItem(name, path, size) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'file-item';
|
||||
item.dataset.path = path;
|
||||
|
||||
item.innerHTML = `
|
||||
<i class="fas fa-image"></i>
|
||||
<span class="item-name">${this.escapeHtml(name)}</span>
|
||||
<span class="item-size">${this.formatFileSize(size)}</span>
|
||||
`;
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to parent directory
|
||||
*/
|
||||
navigateToParentDirectory() {
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
if (currentPath) {
|
||||
// Get parent path using path manipulation
|
||||
const lastSeparator = currentPath.lastIndexOf('/');
|
||||
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
|
||||
this.loadDirectory(parentPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select current directory
|
||||
*/
|
||||
selectCurrentDirectory() {
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
|
||||
if (currentPath && directoryInput) {
|
||||
directoryInput.value = currentPath;
|
||||
this.toggleDirectoryBrowser(); // Close browser
|
||||
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse for directory using File System Access API (deprecated - kept for compatibility)
|
||||
*/
|
||||
async browseDirectory() {
|
||||
// Now redirects to the new directory browser
|
||||
this.toggleDirectoryBrowser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up WebSocket and polling connections
|
||||
*/
|
||||
cleanupConnections() {
|
||||
if (this.wsConnection) {
|
||||
if (this.wsConnection.readyState === WebSocket.OPEN ||
|
||||
this.wsConnection.readyState === WebSocket.CONNECTING) {
|
||||
this.wsConnection.close();
|
||||
}
|
||||
this.wsConnection = null;
|
||||
}
|
||||
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
this.pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const batchImportManager = new BatchImportManager();
|
||||
@@ -568,7 +568,8 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
deselectItem(filepath) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
const escapedPath = this.escapeAttributeValue(filepath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
@@ -632,7 +633,8 @@ export class BulkManager {
|
||||
for (const filepath of state.selectedModels) {
|
||||
const metadata = metadataCache.get(filepath);
|
||||
if (metadata) {
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
const escapedPath = this.escapeAttributeValue(filepath);
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (card) {
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
|
||||
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal file
357
static/js/managers/BulkMissingLoraDownloadManager.js
Normal file
@@ -0,0 +1,357 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
|
||||
/**
|
||||
* Manager for downloading missing LoRAs for selected recipes in bulk
|
||||
*/
|
||||
export class BulkMissingLoraDownloadManager {
|
||||
constructor() {
|
||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||
this.pendingLoras = [];
|
||||
this.pendingRecipes = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect missing LoRAs from selected recipes with deduplication
|
||||
* @param {Array} selectedRecipes - Array of selected recipe objects
|
||||
* @returns {Object} - Object containing unique missing LoRAs and statistics
|
||||
*/
|
||||
collectMissingLoras(selectedRecipes) {
|
||||
const uniqueLoras = new Map(); // key: hash or modelVersionId, value: lora object
|
||||
const missingLorasByRecipe = new Map();
|
||||
let totalMissingCount = 0;
|
||||
|
||||
selectedRecipes.forEach(recipe => {
|
||||
const missingLoras = [];
|
||||
|
||||
if (recipe.loras && Array.isArray(recipe.loras)) {
|
||||
recipe.loras.forEach(lora => {
|
||||
// Only include LoRAs not in library and not deleted
|
||||
if (!lora.inLibrary && !lora.isDeleted) {
|
||||
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
|
||||
|
||||
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
|
||||
// Store the LoRA info
|
||||
uniqueLoras.set(uniqueKey, {
|
||||
...lora,
|
||||
modelId: lora.modelId || lora.model_id,
|
||||
id: lora.id || lora.modelVersionId,
|
||||
});
|
||||
}
|
||||
|
||||
missingLoras.push(lora);
|
||||
totalMissingCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (missingLoras.length > 0) {
|
||||
missingLorasByRecipe.set(recipe.id || recipe.file_path, {
|
||||
recipe,
|
||||
missingLoras
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
uniqueLoras: Array.from(uniqueLoras.values()),
|
||||
uniqueCount: uniqueLoras.size,
|
||||
totalMissingCount,
|
||||
missingLorasByRecipe
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show confirmation modal for downloading missing LoRAs
|
||||
* @param {Object} stats - Statistics about missing LoRAs
|
||||
* @returns {Promise<boolean>} - Whether user confirmed
|
||||
*/
|
||||
async showConfirmationModal(stats) {
|
||||
const { uniqueCount, totalMissingCount, uniqueLoras } = stats;
|
||||
|
||||
if (uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLoras', {}, 'info');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store pending data for confirmation
|
||||
this.pendingLoras = uniqueLoras;
|
||||
|
||||
// Update modal content
|
||||
const messageEl = document.getElementById('bulkDownloadMissingLorasMessage');
|
||||
const listEl = document.getElementById('bulkDownloadMissingLorasList');
|
||||
const confirmBtn = document.getElementById('bulkDownloadMissingLorasConfirmBtn');
|
||||
|
||||
if (messageEl) {
|
||||
messageEl.textContent = translate('modals.bulkDownloadMissingLoras.message', {
|
||||
uniqueCount,
|
||||
totalCount: totalMissingCount
|
||||
}, `Found ${uniqueCount} unique missing LoRAs (from ${totalMissingCount} total across selected recipes).`);
|
||||
}
|
||||
|
||||
if (listEl) {
|
||||
listEl.innerHTML = uniqueLoras.slice(0, 10).map(lora => `
|
||||
<li>
|
||||
<span class="lora-name">${lora.name || lora.file_name || 'Unknown'}</span>
|
||||
${lora.version ? `<span class="lora-version">${lora.version}</span>` : ''}
|
||||
</li>
|
||||
`).join('') +
|
||||
(uniqueLoras.length > 10 ? `
|
||||
<li class="more-items">${translate('modals.bulkDownloadMissingLoras.moreItems', { count: uniqueLoras.length - 10 }, `...and ${uniqueLoras.length - 10} more`)}</li>
|
||||
` : '');
|
||||
}
|
||||
|
||||
if (confirmBtn) {
|
||||
confirmBtn.innerHTML = `
|
||||
<i class="fas fa-download"></i>
|
||||
${translate('modals.bulkDownloadMissingLoras.downloadButton', { count: uniqueCount }, `Download ${uniqueCount} LoRA(s)`)}
|
||||
`;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
modalManager.showModal('bulkDownloadMissingLorasModal');
|
||||
|
||||
// Return a promise that will be resolved when user confirms or cancels
|
||||
return new Promise((resolve) => {
|
||||
this.confirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when user confirms download in modal
|
||||
*/
|
||||
async confirmDownload() {
|
||||
modalManager.closeModal('bulkDownloadMissingLorasModal');
|
||||
|
||||
if (this.confirmResolve) {
|
||||
this.confirmResolve(true);
|
||||
this.confirmResolve = null;
|
||||
}
|
||||
|
||||
// Execute download
|
||||
await this.executeDownload(this.pendingLoras);
|
||||
this.pendingLoras = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Download missing LoRAs for selected recipes
|
||||
* @param {Array} selectedRecipes - Array of selected recipe objects
|
||||
*/
|
||||
async downloadMissingLoras(selectedRecipes) {
|
||||
if (!selectedRecipes || selectedRecipes.length === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Store selected recipes
|
||||
this.pendingRecipes = selectedRecipes;
|
||||
|
||||
// Collect missing LoRAs with deduplication
|
||||
const stats = this.collectMissingLoras(selectedRecipes);
|
||||
|
||||
if (stats.uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation modal
|
||||
const confirmed = await this.showConfirmationModal(stats);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the download process
|
||||
* @param {Array} lorasToDownload - Array of unique LoRAs to download
|
||||
*/
|
||||
async executeDownload(lorasToDownload) {
|
||||
const totalLoras = lorasToDownload.length;
|
||||
|
||||
// Get LoRA root directory
|
||||
const loraRoot = await this.getLoraRoot();
|
||||
if (!loraRoot) {
|
||||
showToast('toast.recipes.noLoraRootConfigured', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate batch download ID
|
||||
const batchDownloadId = Date.now().toString();
|
||||
|
||||
// Use default paths
|
||||
const useDefaultPaths = true;
|
||||
|
||||
// Set up WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
|
||||
|
||||
// Show download progress UI
|
||||
const loadingManager = state.loadingManager;
|
||||
const updateProgress = loadingManager.showDownloadProgress(totalLoras);
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let currentLoraProgress = 0;
|
||||
|
||||
// Set up WebSocket message handler
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Handle download ID confirmation
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process progress updates
|
||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||
currentLoraProgress = data.progress;
|
||||
|
||||
const currentLora = lorasToDownload[completedDownloads + failedDownloads];
|
||||
const loraName = currentLora ? (currentLora.name || currentLora.file_name || 'Unknown') : '';
|
||||
|
||||
const metrics = {
|
||||
bytesDownloaded: data.bytes_downloaded,
|
||||
totalBytes: data.total_bytes,
|
||||
bytesPerSecond: data.bytes_per_second
|
||||
};
|
||||
|
||||
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
|
||||
|
||||
// Update status message
|
||||
if (currentLoraProgress < 3) {
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.startingDownload',
|
||||
{ current: completedDownloads + failedDownloads + 1, total: totalLoras },
|
||||
`Starting download for LoRA ${completedDownloads + failedDownloads + 1}/${totalLoras}`
|
||||
)
|
||||
);
|
||||
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.downloadingLoras', {}, `Downloading LoRAs...`)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for WebSocket to connect
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = resolve;
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
// Download each LoRA sequentially
|
||||
for (let i = 0; i < lorasToDownload.length; i++) {
|
||||
const lora = lorasToDownload[i];
|
||||
|
||||
currentLoraProgress = 0;
|
||||
|
||||
loadingManager.setStatus(
|
||||
translate('recipes.controls.import.startingDownload',
|
||||
{ current: i + 1, total: totalLoras },
|
||||
`Starting download for LoRA ${i + 1}/${totalLoras}`
|
||||
)
|
||||
);
|
||||
updateProgress(0, completedDownloads, lora.name || lora.file_name || 'Unknown');
|
||||
|
||||
try {
|
||||
const modelId = lora.modelId || lora.model_id;
|
||||
const versionId = lora.id || lora.modelVersionId;
|
||||
|
||||
if (!modelId && !versionId) {
|
||||
console.warn(`Skipping LoRA without model/version ID:`, lora);
|
||||
failedDownloads++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await this.loraApiClient.downloadModel(
|
||||
modelId,
|
||||
versionId,
|
||||
loraRoot,
|
||||
'', // Empty relative path, use default paths
|
||||
useDefaultPaths,
|
||||
batchDownloadId
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||
failedDownloads++;
|
||||
} else {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
ws.close();
|
||||
|
||||
// Hide loading UI
|
||||
loadingManager.hide();
|
||||
|
||||
// Show completion message
|
||||
if (failedDownloads === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.downloadPartialSuccess', {
|
||||
completed: completedDownloads,
|
||||
total: totalLoras
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
// Refresh the recipes list to update LoRA status
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.loadRecipes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LoRA root directory from API
|
||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||
*/
|
||||
async getLoraRoot() {
|
||||
try {
|
||||
// Fetch available LoRA roots from API
|
||||
const rootsData = await this.loraApiClient.fetchModelRoots();
|
||||
|
||||
if (!rootsData || !rootsData.roots || rootsData.roots.length === 0) {
|
||||
console.error('No LoRA roots available');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to get default root from settings
|
||||
const defaultRootKey = 'default_lora_root';
|
||||
const defaultRoot = state.global?.settings?.[defaultRootKey];
|
||||
|
||||
// If default root is set and exists in available roots, use it
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
return defaultRoot;
|
||||
}
|
||||
|
||||
// Otherwise, return the first available root
|
||||
return rootsData.roots[0];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error getting LoRA root:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const bulkMissingLoraDownloadManager = new BulkMissingLoraDownloadManager();
|
||||
|
||||
// Make available globally for HTML onclick handlers
|
||||
if (typeof window !== 'undefined') {
|
||||
window.bulkMissingLoraDownloadManager = bulkMissingLoraDownloadManager;
|
||||
}
|
||||
@@ -142,6 +142,28 @@ export class ImportManager {
|
||||
|
||||
// Reset duplicate related properties
|
||||
this.duplicateRecipes = [];
|
||||
|
||||
// Reset button visibility in location step
|
||||
this.resetLocationStepButtons();
|
||||
}
|
||||
|
||||
resetLocationStepButtons() {
|
||||
// Reset buttons to default state
|
||||
const locationStep = document.getElementById('locationStep');
|
||||
if (!locationStep) return;
|
||||
|
||||
const backBtn = locationStep.querySelector('.secondary-btn');
|
||||
const primaryBtn = locationStep.querySelector('.primary-btn');
|
||||
|
||||
// Back button - show
|
||||
if (backBtn) {
|
||||
backBtn.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// Primary button - reset text
|
||||
if (primaryBtn) {
|
||||
primaryBtn.textContent = translate('recipes.controls.import.downloadAndSaveRecipe', {}, 'Download & Save Recipe');
|
||||
}
|
||||
}
|
||||
|
||||
toggleImportMode(mode) {
|
||||
@@ -261,11 +283,57 @@ export class ImportManager {
|
||||
this.loadDefaultPathSetting();
|
||||
|
||||
this.updateTargetPath();
|
||||
|
||||
// Update download button with missing LoRA count (if any)
|
||||
if (this.missingLoras && this.missingLoras.length > 0) {
|
||||
this.updateDownloadButtonCount();
|
||||
this.updateImportButtonsVisibility(true);
|
||||
} else {
|
||||
this.updateImportButtonsVisibility(false);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('toast.recipes.importFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
updateImportButtonsVisibility(hasMissingLoras) {
|
||||
// Update primary button text based on whether there are missing LoRAs
|
||||
const locationStep = document.getElementById('locationStep');
|
||||
if (!locationStep) return;
|
||||
|
||||
const backBtn = locationStep.querySelector('.secondary-btn');
|
||||
const primaryBtn = locationStep.querySelector('.primary-btn');
|
||||
|
||||
// Back button - always show
|
||||
if (backBtn) {
|
||||
backBtn.style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// Update primary button text
|
||||
if (primaryBtn) {
|
||||
const downloadCountSpan = locationStep.querySelector('#downloadLoraCount');
|
||||
if (hasMissingLoras) {
|
||||
// Rebuild button content to ensure proper structure
|
||||
const buttonText = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download');
|
||||
primaryBtn.innerHTML = `${buttonText} <span id="downloadLoraCount"></span>`;
|
||||
} else {
|
||||
primaryBtn.textContent = translate('recipes.controls.import.downloadAndSaveRecipe', {}, 'Download & Save Recipe');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateDownloadButtonCount() {
|
||||
// Update the download count badge on the primary button
|
||||
const locationStep = document.getElementById('locationStep');
|
||||
if (!locationStep) return;
|
||||
|
||||
const downloadCountSpan = locationStep.querySelector('#downloadLoraCount');
|
||||
if (downloadCountSpan) {
|
||||
const missingCount = this.missingLoras?.length || 0;
|
||||
downloadCountSpan.textContent = missingCount > 0 ? `(${missingCount})` : '';
|
||||
}
|
||||
}
|
||||
|
||||
backToUpload() {
|
||||
this.stepManager.showStep('uploadStep');
|
||||
|
||||
@@ -426,12 +494,54 @@ export class ImportManager {
|
||||
const modalTitle = document.querySelector('#importModal h2');
|
||||
if (modalTitle) modalTitle.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
||||
|
||||
// Update the save button text
|
||||
const saveButton = document.querySelector('#locationStep .primary-btn');
|
||||
if (saveButton) saveButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
||||
// Update button texts and show download count
|
||||
const locationStep = document.getElementById('locationStep');
|
||||
if (!locationStep) return;
|
||||
|
||||
const primaryBtn = locationStep.querySelector('.primary-btn');
|
||||
const backBtn = locationStep.querySelector('.secondary-btn');
|
||||
|
||||
// primaryBtn should be the "Import & Download" button
|
||||
if (primaryBtn) {
|
||||
const buttonText = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download');
|
||||
primaryBtn.innerHTML = `${buttonText} <span id="downloadLoraCount">(${recipeData.loras?.length || 0})</span>`;
|
||||
}
|
||||
|
||||
// Hide the "Back" button in download-only mode
|
||||
if (backBtn) {
|
||||
backBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the back button
|
||||
const backButton = document.querySelector('#locationStep .secondary-btn');
|
||||
if (backButton) backButton.style.display = 'none';
|
||||
saveRecipeWithoutDownload() {
|
||||
// Call save recipe with skip download flag
|
||||
return this.downloadManager.saveRecipe(true);
|
||||
}
|
||||
|
||||
async saveRecipeOnlyFromDetails() {
|
||||
// Validate recipe name first
|
||||
if (!this.recipeName) {
|
||||
showToast('toast.recipes.enterRecipeName', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark deleted LoRAs as excluded
|
||||
if (this.recipeData && this.recipeData.loras) {
|
||||
this.recipeData.loras.forEach(lora => {
|
||||
if (lora.isDeleted) {
|
||||
lora.exclude = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update missing LoRAs list
|
||||
this.missingLoras = this.recipeData.loras.filter(lora =>
|
||||
!lora.existsLocally && !lora.isDeleted);
|
||||
|
||||
// For import only, we don't need downloadableLoRAs
|
||||
this.downloadableLoRAs = [];
|
||||
|
||||
// Save recipe without downloading
|
||||
await this.downloadManager.saveRecipe(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add batchImportModal registration
|
||||
const batchImportModal = document.getElementById('batchImportModal');
|
||||
if (batchImportModal) {
|
||||
this.registerModal('batchImportModal', {
|
||||
element: batchImportModal,
|
||||
onClose: () => {
|
||||
this.getModal('batchImportModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
// Add recipeModal registration
|
||||
const recipeModal = document.getElementById('recipeModal');
|
||||
if (recipeModal) {
|
||||
@@ -278,6 +291,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Register bulkDownloadMissingLorasModal
|
||||
const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal');
|
||||
if (bulkDownloadMissingLorasModal) {
|
||||
this.registerModal('bulkDownloadMissingLorasModal', {
|
||||
element: bulkDownloadMissingLorasModal,
|
||||
onClose: () => {
|
||||
this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
|
||||
import { bannerService } from './BannerService.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
@@ -137,11 +139,25 @@ export class SettingsManager {
|
||||
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
|
||||
);
|
||||
|
||||
merged.mature_blur_level = this.normalizeMatureBlurLevel(
|
||||
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
|
||||
);
|
||||
|
||||
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
normalizeMatureBlurLevel(value) {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return 'R';
|
||||
}
|
||||
|
||||
normalizePatternList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
const sanitized = value
|
||||
@@ -682,6 +698,13 @@ export class SettingsManager {
|
||||
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
|
||||
}
|
||||
|
||||
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
|
||||
if (matureBlurLevelSelect) {
|
||||
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
|
||||
state.global.settings.mature_blur_level
|
||||
);
|
||||
}
|
||||
|
||||
const usePortableCheckbox = document.getElementById('usePortableSettings');
|
||||
if (usePortableCheckbox) {
|
||||
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
|
||||
@@ -1811,7 +1834,9 @@ export class SettingsManager {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const value = element.value;
|
||||
const value = settingKey === 'mature_blur_level'
|
||||
? this.normalizeMatureBlurLevel(element.value)
|
||||
: element.value;
|
||||
|
||||
try {
|
||||
// Update frontend state with mapped keys
|
||||
@@ -1834,7 +1859,12 @@ export class SettingsManager {
|
||||
|
||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||
|
||||
if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') {
|
||||
if (
|
||||
settingKey === 'model_name_display'
|
||||
|| settingKey === 'model_card_footer_action'
|
||||
|| settingKey === 'update_flag_strategy'
|
||||
|| settingKey === 'mature_blur_level'
|
||||
) {
|
||||
this.reloadContent();
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export class DownloadManager {
|
||||
this.importManager = importManager;
|
||||
}
|
||||
|
||||
async saveRecipe() {
|
||||
async saveRecipe(skipDownload = false) {
|
||||
// Check if we're in download-only mode (for existing recipe)
|
||||
const isDownloadOnly = !!this.importManager.recipeId;
|
||||
|
||||
@@ -20,7 +20,10 @@ export class DownloadManager {
|
||||
|
||||
try {
|
||||
// Show progress indicator
|
||||
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
|
||||
const loadingMessage = skipDownload
|
||||
? translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...')
|
||||
: (isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
|
||||
this.importManager.loadingManager.showSimpleLoading(loadingMessage);
|
||||
|
||||
// Only send the complete recipe to save if not in download-only mode
|
||||
if (!isDownloadOnly) {
|
||||
@@ -98,15 +101,17 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to download LoRAs
|
||||
// Check if we need to download LoRAs (skip if skipDownload is true)
|
||||
let failedDownloads = 0;
|
||||
if (this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
|
||||
if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
|
||||
await this.downloadMissingLoras();
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (isDownloadOnly) {
|
||||
if (failedDownloads === 0) {
|
||||
if (skipDownload) {
|
||||
showToast('toast.recipes.recipeSaved', {}, 'success');
|
||||
} else if (failedDownloads === 0) {
|
||||
showToast('toast.loras.downloadSuccessful', {}, 'success');
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -325,7 +325,8 @@ export class RecipeDataManager {
|
||||
}
|
||||
|
||||
updateNextButtonState() {
|
||||
const nextButton = document.querySelector('#detailsStep .primary-btn');
|
||||
const nextButton = document.getElementById('nextBtn');
|
||||
const importOnlyBtn = document.getElementById('importOnlyBtn');
|
||||
const actionsContainer = document.querySelector('#detailsStep .modal-actions');
|
||||
if (!nextButton || !actionsContainer) return;
|
||||
|
||||
@@ -365,7 +366,7 @@ export class RecipeDataManager {
|
||||
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
|
||||
}
|
||||
|
||||
// Check for duplicates but don't change button actions
|
||||
// Check for downloadable missing LoRAs
|
||||
const missingNotDeleted = this.importManager.recipeData.loras.filter(
|
||||
lora => !lora.existsLocally && !lora.isDeleted
|
||||
).length;
|
||||
@@ -374,8 +375,16 @@ export class RecipeDataManager {
|
||||
nextButton.classList.remove('warning-btn');
|
||||
|
||||
if (missingNotDeleted > 0) {
|
||||
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
|
||||
// Show import only button and update primary button
|
||||
if (importOnlyBtn) {
|
||||
importOnlyBtn.style.display = 'inline-block';
|
||||
}
|
||||
nextButton.textContent = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download') + ` (${missingNotDeleted})`;
|
||||
} else {
|
||||
// Hide import only button and show save recipe
|
||||
if (importOnlyBtn) {
|
||||
importOnlyBtn.style.display = 'none';
|
||||
}
|
||||
nextButton.textContent = translate('recipes.controls.import.saveRecipe', {}, 'Save Recipe');
|
||||
}
|
||||
}
|
||||
@@ -440,8 +449,11 @@ export class RecipeDataManager {
|
||||
// Store only downloadable LoRAs for the download step
|
||||
this.importManager.downloadableLoRAs = this.importManager.missingLoras;
|
||||
this.importManager.proceedToLocation();
|
||||
} else if (this.importManager.missingLoras.length === 0 && this.importManager.recipeData.loras.some(l => !l.existsLocally)) {
|
||||
// All missing LoRAs are deleted, save recipe without download
|
||||
this.importManager.saveRecipe();
|
||||
} else {
|
||||
// Otherwise, save the recipe directly
|
||||
// No missing LoRAs at all, save the recipe directly
|
||||
this.importManager.saveRecipe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// Recipe manager module
|
||||
import { appCore } from './core.js';
|
||||
import { ImportManager } from './managers/ImportManager.js';
|
||||
import { BatchImportManager } from './managers/BatchImportManager.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
import { state, getCurrentPageState } from './state/index.js';
|
||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||
import { sidebarManager } from './components/SidebarManager.js';
|
||||
|
||||
class RecipePageControls {
|
||||
@@ -27,7 +28,7 @@ class RecipePageControls {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshVirtualScroll();
|
||||
await syncChanges();
|
||||
}
|
||||
|
||||
getSidebarApiClient() {
|
||||
@@ -46,6 +47,10 @@ class RecipeManager {
|
||||
// Initialize ImportManager
|
||||
this.importManager = new ImportManager();
|
||||
|
||||
// Initialize BatchImportManager and make it globally accessible
|
||||
this.batchImportManager = new BatchImportManager();
|
||||
window.batchImportManager = this.batchImportManager;
|
||||
|
||||
// Initialize RecipeModal
|
||||
this.recipeModal = new RecipeModal();
|
||||
|
||||
@@ -236,6 +241,70 @@ class RecipeManager {
|
||||
refreshVirtualScroll();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize dropdown functionality for refresh button
|
||||
this.initDropdowns();
|
||||
}
|
||||
|
||||
initDropdowns() {
|
||||
// Handle dropdown toggles
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
|
||||
dropdownToggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const dropdownGroup = toggle.closest('.dropdown-group');
|
||||
|
||||
// Close all other open dropdowns first
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
if (group !== dropdownGroup) {
|
||||
group.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
dropdownGroup.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle quick refresh option (Sync Changes)
|
||||
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
||||
if (quickRefreshOption) {
|
||||
quickRefreshOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.pageControls.refreshModels(false);
|
||||
this.closeDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle full rebuild option (Rebuild Cache)
|
||||
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
||||
if (fullRebuildOption) {
|
||||
fullRebuildOption.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.pageControls.refreshModels(true);
|
||||
this.closeDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle main refresh button (default: sync changes)
|
||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.pageControls.refreshModels(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.dropdown-group')) {
|
||||
this.closeDropdowns();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
closeDropdowns() {
|
||||
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
||||
group.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
// This method is kept for compatibility but now uses virtual scrolling
|
||||
|
||||
@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
optimize_example_images: true,
|
||||
auto_download_example_images: false,
|
||||
blur_mature_content: true,
|
||||
mature_blur_level: 'R',
|
||||
autoplay_on_hover: false,
|
||||
display_density: 'default',
|
||||
card_info_display: 'always',
|
||||
|
||||
@@ -309,6 +309,15 @@ export const NSFW_LEVELS = {
|
||||
BLOCKED: 32
|
||||
};
|
||||
|
||||
export const VALID_MATURE_BLUR_LEVELS = ['PG13', 'R', 'X', 'XXX'];
|
||||
|
||||
export function getMatureBlurThreshold(settings = {}) {
|
||||
const rawValue = settings?.mature_blur_level;
|
||||
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : '';
|
||||
const levelName = VALID_MATURE_BLUR_LEVELS.includes(normalizedValue) ? normalizedValue : 'R';
|
||||
return NSFW_LEVELS[levelName] ?? NSFW_LEVELS.R;
|
||||
}
|
||||
|
||||
// Node type constants
|
||||
export const NODE_TYPES = {
|
||||
LORA_LOADER: 1,
|
||||
|
||||
@@ -7,7 +7,10 @@ let pendingExcludePath = null;
|
||||
export function showDeleteModal(filePath) {
|
||||
pendingDeletePath = filePath;
|
||||
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||
const modal = modalManager.getModal('deleteModal').element;
|
||||
const modelInfo = modal.querySelector('.delete-model-info');
|
||||
@@ -47,7 +50,10 @@ export function closeDeleteModal() {
|
||||
export function showExcludeModal(filePath) {
|
||||
pendingExcludePath = filePath;
|
||||
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||
const modal = modalManager.getModal('excludeModal').element;
|
||||
const modelInfo = modal.querySelector('.exclude-model-info');
|
||||
|
||||
@@ -197,7 +197,10 @@ export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
|
||||
}
|
||||
|
||||
export function openCivitai(filePath) {
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${filePath}"]`);
|
||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||
? window.CSS.escape(filePath)
|
||||
: filePath.replace(/["\\]/g, '\\$&');
|
||||
const loraCard = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||
if (!loraCard) return;
|
||||
|
||||
const metaData = JSON.parse(loraCard.dataset.meta);
|
||||
@@ -483,8 +486,12 @@ async function ensureRelativeModelPath(modelPath, collectionType) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
// Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching
|
||||
// Backend removes extensions from paths before matching, so search term should not include extension
|
||||
const searchTerm = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(fileName)}&limit=10`);
|
||||
const response = await fetch(`/api/lm/${collectionType}/relative-paths?search=${encodeURIComponent(searchTerm)}&limit=10`);
|
||||
if (!response.ok) {
|
||||
return modelPath;
|
||||
}
|
||||
|
||||
206
templates/components/batch_import_modal.html
Normal file
206
templates/components/batch_import_modal.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<div id="batchImportModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button class="close" onclick="modalManager.closeModal('batchImportModal')">×</button>
|
||||
<h2>{{ t('recipes.batchImport.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Input Selection -->
|
||||
<div class="batch-import-step" id="batchInputStep">
|
||||
<div class="import-mode-toggle">
|
||||
<button class="toggle-btn active" data-mode="urls" onclick="batchImportManager.toggleInputMode('urls')">
|
||||
<i class="fas fa-link"></i> {{ t('recipes.batchImport.urlList') }}
|
||||
</button>
|
||||
<button class="toggle-btn" data-mode="directory" onclick="batchImportManager.toggleInputMode('directory')">
|
||||
<i class="fas fa-folder"></i> {{ t('recipes.batchImport.directory') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- URL List Section -->
|
||||
<div class="import-section" id="urlListSection">
|
||||
<p class="section-description">{{ t('recipes.batchImport.urlDescription') }}</p>
|
||||
<div class="input-group">
|
||||
<label for="batchUrlInput">{{ t('recipes.batchImport.urlsLabel') }}</label>
|
||||
<textarea id="batchUrlInput" rows="8" placeholder="{{ t('recipes.batchImport.urlsPlaceholder') }}"></textarea>
|
||||
<div class="input-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{{ t('recipes.batchImport.urlsHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory Section -->
|
||||
<div class="import-section" id="directorySection" style="display: none;">
|
||||
<p class="section-description">{{ t('recipes.batchImport.directoryDescription') }}</p>
|
||||
<div class="input-group">
|
||||
<label for="batchDirectoryInput">{{ t('recipes.batchImport.directoryPath') }}</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="batchDirectoryInput" placeholder="{{ t('recipes.batchImport.directoryPlaceholder') }}" autocomplete="off">
|
||||
<button class="secondary-btn" onclick="batchImportManager.toggleDirectoryBrowser()">
|
||||
<i class="fas fa-folder-open"></i> {{ t('recipes.batchImport.browse') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory Browser -->
|
||||
<div class="directory-browser" id="batchDirectoryBrowser" style="display: none;">
|
||||
<div class="browser-header">
|
||||
<button class="back-btn" onclick="batchImportManager.navigateToParentDirectory()" title="{{ t('recipes.batchImport.backToParent') }}">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<div class="current-path" id="batchCurrentPath"></div>
|
||||
</div>
|
||||
<div class="browser-content">
|
||||
<div class="browser-section">
|
||||
<div class="section-label"><i class="fas fa-folder"></i> {{ t('recipes.batchImport.folders') }}</div>
|
||||
<div class="folder-list" id="batchFolderList"></div>
|
||||
</div>
|
||||
<div class="browser-section">
|
||||
<div class="section-label"><i class="fas fa-image"></i> {{ t('recipes.batchImport.imageFiles') }}</div>
|
||||
<div class="file-list" id="batchFileList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="browser-footer">
|
||||
<div class="stats">
|
||||
<span id="batchDirectoryCount">0</span> {{ t('recipes.batchImport.folders') }},
|
||||
<span id="batchImageCount">0</span> {{ t('recipes.batchImport.images') }}
|
||||
</div>
|
||||
<button class="primary-btn" onclick="batchImportManager.selectCurrentDirectory()">
|
||||
<i class="fas fa-check"></i> {{ t('recipes.batchImport.selectFolder') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="batchRecursiveCheck" checked>
|
||||
<span class="checkmark"></span>
|
||||
{{ t('recipes.batchImport.recursive') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Options -->
|
||||
<div class="batch-options">
|
||||
<div class="input-group">
|
||||
<label for="batchTagsInput">{{ t('recipes.batchImport.tagsOptional') }}</label>
|
||||
<input type="text" id="batchTagsInput" placeholder="{{ t('recipes.batchImport.tagsPlaceholder') }}">
|
||||
<div class="input-hint">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{{ t('recipes.batchImport.tagsHint') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="batchSkipNoMetadata">
|
||||
<span class="checkmark"></span>
|
||||
{{ t('recipes.batchImport.skipNoMetadata') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" onclick="modalManager.closeModal('batchImportModal')">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" id="batchImportStartBtn" onclick="batchImportManager.startImport()">
|
||||
<i class="fas fa-play"></i> {{ t('recipes.batchImport.start') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Progress -->
|
||||
<div class="batch-import-step" id="batchProgressStep" style="display: none;">
|
||||
<div class="batch-progress-container">
|
||||
<div class="progress-header">
|
||||
<div class="progress-status">
|
||||
<span class="status-icon"><i class="fas fa-spinner fa-spin"></i></span>
|
||||
<span class="status-text" id="batchStatusText">{{ t('recipes.batchImport.importing') }}</span>
|
||||
</div>
|
||||
<div class="progress-percentage" id="batchProgressPercent">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="batchProgressBar" style="width: 0%"></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.total') }}</span>
|
||||
<span class="stat-value" id="batchTotalCount">0</span>
|
||||
</div>
|
||||
<div class="stat-item success">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.success') }}</span>
|
||||
<span class="stat-value" id="batchSuccessCount">0</span>
|
||||
</div>
|
||||
<div class="stat-item failed">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.failed') }}</span>
|
||||
<span class="stat-value" id="batchFailedCount">0</span>
|
||||
</div>
|
||||
<div class="stat-item skipped">
|
||||
<span class="stat-label">{{ t('recipes.batchImport.skipped') }}</span>
|
||||
<span class="stat-value" id="batchSkippedCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="current-item" id="batchCurrentItemContainer">
|
||||
<span class="current-item-label">{{ t('recipes.batchImport.current') }}</span>
|
||||
<span class="current-item-name" id="batchCurrentItem">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" id="batchCancelBtn" onclick="batchImportManager.cancelImport()">
|
||||
<i class="fas fa-stop"></i> {{ t('recipes.batchImport.cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Results -->
|
||||
<div class="batch-import-step" id="batchResultsStep" style="display: none;">
|
||||
<div class="batch-results-container">
|
||||
<div class="results-header" id="batchResultsHeader">
|
||||
<div class="results-icon">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
</div>
|
||||
<div class="results-title">{{ t('recipes.batchImport.completed') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="results-summary">
|
||||
<div class="result-card total">
|
||||
<span class="result-label">{{ t('recipes.batchImport.total') }}</span>
|
||||
<span class="result-value" id="resultsTotal">0</span>
|
||||
</div>
|
||||
<div class="result-card success">
|
||||
<span class="result-label">{{ t('recipes.batchImport.success') }}</span>
|
||||
<span class="result-value" id="resultsSuccess">0</span>
|
||||
</div>
|
||||
<div class="result-card failed">
|
||||
<span class="result-label">{{ t('recipes.batchImport.failed') }}</span>
|
||||
<span class="result-value" id="resultsFailed">0</span>
|
||||
</div>
|
||||
<div class="result-card skipped">
|
||||
<span class="result-label">{{ t('recipes.batchImport.skipped') }}</span>
|
||||
<span class="result-value" id="resultsSkipped">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-details" id="batchResultsDetails">
|
||||
<div class="details-toggle" onclick="batchImportManager.toggleResultsDetails()">
|
||||
<i class="fas fa-chevron-down" id="resultsToggleIcon"></i>
|
||||
<span>{{ t('recipes.batchImport.viewDetails') }}</span>
|
||||
</div>
|
||||
<div class="details-list" id="batchDetailsList" style="display: none;">
|
||||
<!-- Details will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" onclick="batchImportManager.closeAndReset()">{{ t('common.actions.close') }}</button>
|
||||
<button class="primary-btn" onclick="batchImportManager.startNewImport()">
|
||||
<i class="fas fa-plus"></i> {{ t('recipes.batchImport.newImport') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,6 +87,9 @@
|
||||
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item" data-action="download-missing-loras">
|
||||
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="move-all">
|
||||
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -92,9 +92,10 @@
|
||||
<!-- Duplicate recipes will be populated here -->
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<div class="modal-actions" id="detailsStepActions">
|
||||
<button class="secondary-btn" onclick="importManager.backToUpload()">{{ t('common.actions.back') }}</button>
|
||||
<button class="primary-btn" onclick="importManager.proceedFromDetails()">{{ t('common.actions.next') }}</button>
|
||||
<button class="secondary-btn" id="importOnlyBtn" onclick="importManager.saveRecipeOnlyFromDetails()" style="display: none;">{{ t('recipes.controls.import.importRecipeOnly') }}</button>
|
||||
<button class="primary-btn" id="nextBtn" onclick="importManager.proceedFromDetails()">{{ t('common.actions.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +160,7 @@
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" onclick="importManager.backToDetails()">{{ t('common.actions.back') }}</button>
|
||||
<button class="primary-btn" onclick="importManager.saveRecipe()">{{ t('recipes.controls.import.downloadAndSaveRecipe') }}</button>
|
||||
<button class="primary-btn" onclick="importManager.saveRecipe()">{{ t('recipes.controls.import.importAndDownload') }} <span id="downloadLoraCount"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,4 +80,32 @@
|
||||
<button class="primary-btn" data-action="confirm-check-updates">{{ t('modals.checkUpdates.action') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
|
||||
<div id="bulkDownloadMissingLorasModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ t('modals.bulkDownloadMissingLoras.title') }}</h2>
|
||||
<span class="close" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="confirmation-message" id="bulkDownloadMissingLorasMessage"></p>
|
||||
<div class="bulk-download-loras-preview" id="bulkDownloadMissingLorasPreview">
|
||||
<p class="preview-title">{{ t('modals.bulkDownloadMissingLoras.previewTitle') }}</p>
|
||||
<ul class="bulk-download-loras-list" id="bulkDownloadMissingLorasList"></ul>
|
||||
</div>
|
||||
<p class="confirmation-note">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
{{ t('modals.bulkDownloadMissingLoras.note') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" id="bulkDownloadMissingLorasConfirmBtn" onclick="bulkMissingLoraDownloadManager.confirmDownload()">
|
||||
<i class="fas fa-download"></i>
|
||||
{{ t('modals.bulkDownloadMissingLoras.downloadButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,6 +281,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="matureBlurLevel">
|
||||
{{ t('settings.contentFiltering.matureBlurThreshold') }}
|
||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.contentFiltering.matureBlurThresholdHelp') }}"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="matureBlurLevel"
|
||||
onchange="settingsManager.saveSelectSetting('matureBlurLevel', 'mature_blur_level')">
|
||||
<option value="PG13">{{ t('settings.contentFiltering.matureBlurThresholdOptions.pg13') }}</option>
|
||||
<option value="R">{{ t('settings.contentFiltering.matureBlurThresholdOptions.r') }}</option>
|
||||
<option value="X">{{ t('settings.contentFiltering.matureBlurThresholdOptions.x') }}</option>
|
||||
<option value="XXX">{{ t('settings.contentFiltering.matureBlurThresholdOptions.xxx') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Settings -->
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/recipe-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/import-modal.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_components %}
|
||||
{% include 'components/import_modal.html' %}
|
||||
{% include 'components/batch_import_modal.html' %}
|
||||
{% include 'components/recipe_modal.html' %}
|
||||
|
||||
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||
@@ -66,15 +68,29 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{
|
||||
t('common.actions.refresh')
|
||||
}}</button>
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
|
||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
|
||||
t('common.actions.refresh') }}</span></button>
|
||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||
<i class="fas fa-caret-down"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-item" data-action="quick-refresh" title="{{ t('recipes.controls.refresh.quickTooltip', default='Sync changes - quick refresh without rebuilding cache') }}">
|
||||
<i class="fas fa-bolt"></i> <span>{{ t('loras.controls.refresh.quick', default='Sync Changes') }}</span>
|
||||
</div>
|
||||
<div class="dropdown-item" data-action="full-rebuild" title="{{ t('recipes.controls.refresh.fullTooltip', default='Rebuild cache - full rescan of all recipe files') }}">
|
||||
<i class="fas fa-tools"></i> <span>{{ t('loras.controls.refresh.full', default='Rebuild Cache') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||
t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.batchImport.title') }}" class="control-group">
|
||||
<button onclick="batchImportManager.showModal()"><i class="fas fa-layer-group"></i> {{
|
||||
t('recipes.batchImport.action') }}</button>
|
||||
</div>
|
||||
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||
|
||||
@@ -36,8 +36,8 @@ class TestCheckpointPathOverlap:
|
||||
config._preview_root_paths = set()
|
||||
config._cached_fingerprint = None
|
||||
|
||||
# Call the method under test
|
||||
result = config._prepare_checkpoint_paths(
|
||||
# Call the method under test - now returns a tuple
|
||||
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
|
||||
[str(checkpoints_link)], [str(unet_link)]
|
||||
)
|
||||
|
||||
@@ -50,21 +50,27 @@ class TestCheckpointPathOverlap:
|
||||
]
|
||||
assert len(warning_messages) == 1
|
||||
assert "checkpoints" in warning_messages[0].lower()
|
||||
assert "diffusion_models" in warning_messages[0].lower() or "unet" in warning_messages[0].lower()
|
||||
assert (
|
||||
"diffusion_models" in warning_messages[0].lower()
|
||||
or "unet" in warning_messages[0].lower()
|
||||
)
|
||||
# Verify warning mentions backward compatibility fallback
|
||||
assert "falling back" in warning_messages[0].lower() or "backward compatibility" in warning_messages[0].lower()
|
||||
assert (
|
||||
"falling back" in warning_messages[0].lower()
|
||||
or "backward compatibility" in warning_messages[0].lower()
|
||||
)
|
||||
|
||||
# Verify only one path is returned (deduplication still works)
|
||||
assert len(result) == 1
|
||||
assert len(all_paths) == 1
|
||||
# Prioritizes checkpoints path for backward compatibility
|
||||
assert _normalize(result[0]) == _normalize(str(checkpoints_link))
|
||||
assert _normalize(all_paths[0]) == _normalize(str(checkpoints_link))
|
||||
|
||||
# Verify checkpoints_roots has the path (prioritized)
|
||||
assert len(config.checkpoints_roots) == 1
|
||||
assert _normalize(config.checkpoints_roots[0]) == _normalize(str(checkpoints_link))
|
||||
# Verify checkpoint_roots has the path (prioritized)
|
||||
assert len(checkpoint_roots) == 1
|
||||
assert _normalize(checkpoint_roots[0]) == _normalize(str(checkpoints_link))
|
||||
|
||||
# Verify unet_roots is empty (overlapping paths removed)
|
||||
assert config.unet_roots == []
|
||||
assert unet_roots == []
|
||||
|
||||
def test_non_overlapping_paths_no_warning(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
|
||||
@@ -83,7 +89,7 @@ class TestCheckpointPathOverlap:
|
||||
config._preview_root_paths = set()
|
||||
config._cached_fingerprint = None
|
||||
|
||||
result = config._prepare_checkpoint_paths(
|
||||
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
|
||||
[str(checkpoints_dir)], [str(unet_dir)]
|
||||
)
|
||||
|
||||
@@ -97,14 +103,14 @@ class TestCheckpointPathOverlap:
|
||||
assert len(warning_messages) == 0
|
||||
|
||||
# Verify both paths are returned
|
||||
assert len(result) == 2
|
||||
normalized_result = [_normalize(p) for p in result]
|
||||
assert len(all_paths) == 2
|
||||
normalized_result = [_normalize(p) for p in all_paths]
|
||||
assert _normalize(str(checkpoints_dir)) in normalized_result
|
||||
assert _normalize(str(unet_dir)) in normalized_result
|
||||
|
||||
# Verify both roots are properly set
|
||||
assert len(config.checkpoints_roots) == 1
|
||||
assert len(config.unet_roots) == 1
|
||||
assert len(checkpoint_roots) == 1
|
||||
assert len(unet_roots) == 1
|
||||
|
||||
def test_partial_overlap_prioritizes_checkpoints(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog
|
||||
@@ -129,9 +135,9 @@ class TestCheckpointPathOverlap:
|
||||
config._cached_fingerprint = None
|
||||
|
||||
# One checkpoint path overlaps with one unet path
|
||||
result = config._prepare_checkpoint_paths(
|
||||
all_paths, checkpoint_roots, unet_roots = config._prepare_checkpoint_paths(
|
||||
[str(shared_link), str(separate_checkpoint)],
|
||||
[str(shared_link), str(separate_unet)]
|
||||
[str(shared_link), str(separate_unet)],
|
||||
)
|
||||
|
||||
# Verify warning was logged for the overlapping path
|
||||
@@ -144,17 +150,20 @@ class TestCheckpointPathOverlap:
|
||||
assert len(warning_messages) == 1
|
||||
|
||||
# Verify 3 unique paths (shared counted once as checkpoint, plus separate ones)
|
||||
assert len(result) == 3
|
||||
assert len(all_paths) == 3
|
||||
|
||||
# Verify the overlapping path appears in warning message
|
||||
assert str(shared_link.name) in warning_messages[0] or str(shared_dir.name) in warning_messages[0]
|
||||
assert (
|
||||
str(shared_link.name) in warning_messages[0]
|
||||
or str(shared_dir.name) in warning_messages[0]
|
||||
)
|
||||
|
||||
# Verify checkpoints_roots includes both checkpoint paths (including the shared one)
|
||||
assert len(config.checkpoints_roots) == 2
|
||||
checkpoint_normalized = [_normalize(p) for p in config.checkpoints_roots]
|
||||
# Verify checkpoint_roots includes both checkpoint paths (including the shared one)
|
||||
assert len(checkpoint_roots) == 2
|
||||
checkpoint_normalized = [_normalize(p) for p in checkpoint_roots]
|
||||
assert _normalize(str(shared_link)) in checkpoint_normalized
|
||||
assert _normalize(str(separate_checkpoint)) in checkpoint_normalized
|
||||
|
||||
# Verify unet_roots only includes the non-overlapping unet path
|
||||
assert len(config.unet_roots) == 1
|
||||
assert _normalize(config.unet_roots[0]) == _normalize(str(separate_unet))
|
||||
assert len(unet_roots) == 1
|
||||
assert _normalize(unet_roots[0]) == _normalize(str(separate_unet))
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('AutoComplete widget interactions', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=20');
|
||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
||||
const items = autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item');
|
||||
expect(items).toHaveLength(1);
|
||||
expect(autoComplete.dropdown.style.display).toBe('block');
|
||||
@@ -156,4 +156,542 @@ describe('AutoComplete widget interactions', () => {
|
||||
expect(highlighted).toContain('detail');
|
||||
expect(highlighted).not.toMatch(/beta<\/span>/i);
|
||||
});
|
||||
|
||||
it('handles arrow key navigation with virtual scrolling', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockItems = Array.from({ length: 50 }, (_, i) => `model_${i.toString().padStart(2, '0')}.safetensors`);
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('model');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
enableVirtualScroll: true,
|
||||
itemHeight: 40,
|
||||
visibleItems: 15,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
input.value = 'model';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.items.length).toBeGreaterThan(0);
|
||||
expect(autoComplete.selectedIndex).toBe(0);
|
||||
|
||||
const initialSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||
expect(initialSelectedEl).toBeDefined();
|
||||
|
||||
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
|
||||
input.dispatchEvent(arrowDownEvent);
|
||||
|
||||
expect(autoComplete.selectedIndex).toBe(1);
|
||||
|
||||
const secondSelectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||
expect(secondSelectedEl).toBeDefined();
|
||||
expect(secondSelectedEl?.dataset.index).toBe('1');
|
||||
|
||||
const arrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true });
|
||||
input.dispatchEvent(arrowUpEvent);
|
||||
|
||||
expect(autoComplete.selectedIndex).toBe(0);
|
||||
|
||||
const firstSelectedElAgain = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||
expect(firstSelectedElAgain).toBeDefined();
|
||||
expect(firstSelectedElAgain?.dataset.index).toBe('0');
|
||||
});
|
||||
|
||||
it('maintains selection when scrolling to invisible items', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockItems = Array.from({ length: 100 }, (_, i) => `item_${i.toString().padStart(3, '0')}.safetensors`);
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, relative_paths: mockItems }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('item');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.style.width = '400px';
|
||||
input.style.height = '200px';
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'loras', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
enableVirtualScroll: true,
|
||||
itemHeight: 40,
|
||||
visibleItems: 15,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
input.value = 'item';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.items.length).toBeGreaterThan(0);
|
||||
|
||||
autoComplete.selectedIndex = 14;
|
||||
|
||||
const scrollTopBefore = autoComplete.scrollContainer?.scrollTop || 0;
|
||||
|
||||
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true });
|
||||
input.dispatchEvent(arrowDownEvent);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(autoComplete.selectedIndex).toBe(15);
|
||||
|
||||
const selectedEl = autoComplete.contentContainer?.querySelector('.comfy-autocomplete-item-selected');
|
||||
expect(selectedEl).toBeDefined();
|
||||
expect(selectedEl?.dataset.index).toBe('15');
|
||||
|
||||
const scrollTopAfter = autoComplete.scrollContainer?.scrollTop || 0;
|
||||
expect(scrollTopAfter).toBeGreaterThanOrEqual(scrollTopBefore);
|
||||
});
|
||||
|
||||
it('replaces entire multi-word phrase when it matches selected tag (Danbooru convention)', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||
{ tag_name: 'looking_away', category: 0, post_count: 5678 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'looking to the side';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
expect(autoComplete.dropdown.style.display).toBe('none');
|
||||
expect(input.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces only last token when typing partial match (e.g., "hello 1gi" -> "1girl")', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: '1girl', category: 4, post_count: 500000 },
|
||||
{ tag_name: '1boy', category: 4, post_count: 300000 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('hello 1gi');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'hello 1gi';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'hello 1gi';
|
||||
|
||||
await autoComplete.insertSelection('1girl');
|
||||
|
||||
expect(input.value).toBe('hello 1girl, ');
|
||||
});
|
||||
|
||||
it('replaces entire phrase for underscore tag match (e.g., "blue hair" -> "blue_hair")', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'blue hair';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'blue hair';
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
});
|
||||
|
||||
it('handles multi-word phrase with preceding text correctly', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl, looking to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '1girl, looking to the side';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'looking to the side';
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
expect(input.value).toBe('1girl, looking_to_the_side, ');
|
||||
});
|
||||
|
||||
it('replaces entire command and search term when using command mode with multi-word phrase', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'looking_to_the_side', category: 4, post_count: 1234 },
|
||||
{ tag_name: 'looking_away', category: 4, post_count: 5678 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// Simulate "/char looking to the side" input
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/char looking to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/char looking to the side';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
// Set up command mode state
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = { categories: [4, 11], label: 'Character' };
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = '/char looking to the side';
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Command part should be replaced along with search term
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
});
|
||||
|
||||
it('replaces only last token when multi-word query does not exactly match selected tag', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||
{ tag_name: 'blue_eyes', category: 0, post_count: 80000 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// User types "looking to the blue" but selects "blue_hair" (doesn't match entire phrase)
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the blue');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'looking to the blue';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'looking to the blue';
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// Only "blue" should be replaced, not the entire phrase
|
||||
expect(input.value).toBe('looking to the blue_hair, ');
|
||||
});
|
||||
|
||||
it('handles multiple consecutive spaces in multi-word phrase correctly', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// Input with multiple spaces between words
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'looking to the side';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'looking to the side';
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Multiple spaces should be normalized to single underscores for matching
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
});
|
||||
|
||||
it('handles command mode with partial match replacing only last token', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// Command mode but selected tag doesn't match entire search phrase
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/general looking to the blue');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = '/general looking to the blue';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
// Command mode with activeCommand
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = { categories: [0, 7], label: 'General' };
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = '/general looking to the blue';
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// In command mode, the entire command + search term should be replaced
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
});
|
||||
|
||||
it('replaces entire phrase when selected tag starts with underscore version of search term (prefix match)', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// User types partial phrase "looking to the" and selects "looking_to_the_side"
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('looking to the');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'looking to the';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'looking to the';
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Entire phrase should be replaced with selected tag (with underscores)
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
});
|
||||
|
||||
it('inserts tag with underscores regardless of space replacement setting', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('blue');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'blue';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
|
||||
await autoComplete.insertSelection('blue_hair');
|
||||
|
||||
// Tag should be inserted with underscores, not spaces
|
||||
expect(input.value).toBe('blue_hair, ');
|
||||
});
|
||||
|
||||
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
|
||||
const mockTags = [
|
||||
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },
|
||||
];
|
||||
|
||||
fetchApiMock.mockResolvedValue({
|
||||
json: () => Promise.resolve({ success: true, words: mockTags }),
|
||||
});
|
||||
|
||||
// User types suffix "to the side" and selects "looking_to_the_side"
|
||||
caretHelperInstance.getBeforeCursor.mockReturnValue('to the side');
|
||||
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
|
||||
|
||||
const input = document.createElement('textarea');
|
||||
input.value = 'to the side';
|
||||
input.selectionStart = input.value.length;
|
||||
input.focus = vi.fn();
|
||||
input.setSelectionRange = vi.fn();
|
||||
document.body.append(input);
|
||||
|
||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||
const autoComplete = new AutoComplete(input, 'prompt', {
|
||||
debounceDelay: 0,
|
||||
showPreview: false,
|
||||
minChars: 1,
|
||||
});
|
||||
|
||||
autoComplete.searchType = 'custom_words';
|
||||
autoComplete.activeCommand = null;
|
||||
autoComplete.items = mockTags;
|
||||
autoComplete.selectedIndex = 0;
|
||||
autoComplete.currentSearchTerm = 'to the side';
|
||||
|
||||
await autoComplete.insertSelection('looking_to_the_side');
|
||||
|
||||
// Entire phrase should be replaced with selected tag
|
||||
expect(input.value).toBe('looking_to_the_side, ');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,8 @@ describe('state module', () => {
|
||||
expect(defaultSettings).toMatchObject({
|
||||
civitai_api_key: '',
|
||||
language: 'en',
|
||||
blur_mature_content: true
|
||||
blur_mature_content: true,
|
||||
mature_blur_level: 'R'
|
||||
});
|
||||
|
||||
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user