Compare commits

...

51 Commits

Author SHA1 Message Date
Will Miao
9e81c33f8a fix(utils): make sanitize_folder_name idempotent by combining strip/rstrip calls 2026-03-17 11:24:59 +08:00
Will Miao
22c0dbd734 feat(recipes): persist 'Skip images without metadata' choice in batch import 2026-03-17 11:01:41 +08:00
Will Miao
d0c58472be fix(i18n): add missing common.actions.close translation key 2026-03-17 09:57:27 +08:00
Will Miao
b3c530bf36 fix(autocomplete): handle multi-word tag matching with normalized spaces
- Replace multiple consecutive spaces with single underscore for tag matching
  (e.g., 'looking  to   the side' → 'looking_to_the_side')
- Support prefix/suffix matching for flexible multi-word autocomplete
  (e.g., 'looking to the' → 'looking_to_the_side')
- Add comprehensive test coverage for multi-word scenarios

Test coverage:
- Multi-word exact match (Danbooru convention)
- Partial match with last token replacement
- Command mode with multi-word phrases
- Multiple consecutive spaces handling
- Backend LOG10 popularity weight validation

Fixes: 'looking to the side' input now correctly replaces with
'looking_to_the_side, ' (or 'looking to the side, ' with space replacement)
2026-03-17 09:34:01 +08:00
Will Miao
b5a0725d2c fix(autocomplete): improve tag search ranking with popularity-based sorting
- Add LOG10(post_count) weighting to BM25 score for better relevance ranking
- Prioritize tag_name prefix matches above alias matches using CASE statement
- Remove frontend re-scoring logic to trust backend排序 results
- Fix pagination consistency: page N+1 scores <= page N minimum score

Key improvements:
- '1girl' (6M posts) now ranks #1 instead of #149 for search '1'
- tag_name prefix matches always appear before alias matches
- Popular tags rank higher than obscure ones with same prefix
- Consistent ordering across pagination boundaries

Test coverage:
- Add test_search_tag_name_prefix_match_priority
- Add test_search_ranks_popular_tags_higher
- Add test_search_pagination_ordering_consistency
- Add test_search_rank_score_includes_popularity_weight
- Update test data with 15 tags starting with '1'

Fixes issues with autocomplete dropdown showing inconsistent results
when scrolling through paginated search results.
2026-03-16 19:09:07 +08:00
Will Miao
ef38bda04f docs: remove redundant example metadata files (#856)
- Delete examples/metadata/ directory and all example files
  - Real metadata.json files in model roots are better examples
  - Examples were artificial and could become outdated
  - Maintenance burden outweighs benefit

- Remove 'Complete Examples' section from docs/metadata-json-schema.md
- Remove reference to example files in 'See Also' section

Rationale:
Users have access to real-world metadata.json files in their actual
model directories, which contain complete Civitai API responses with
authentic data structures (images arrays with prompts, files with hashes,
creator information, etc.). These are more valuable than simplified
artificial examples.
2026-03-16 09:41:58 +08:00
Will Miao
58713ea6e0 fix(top-menu): use dynamic imports to eliminate deprecation warnings
- Replace static imports of deprecated ComfyButton and ComfyButtonGroup with dynamic imports
- Only loads legacy API files when frontend version < 1.33.9 (backward compatibility path)
- Frontend >= 1.33.9 users no longer see deprecation warnings since legacy code is never loaded
- Preserves full backward compatibility for older ComfyUI frontend versions
- All existing tests pass (159 JS + 65 Vue tests)
2026-03-16 09:41:58 +08:00
Will Miao
8b91920058 docs: add comprehensive metadata.json schema documentation (#856)
- Create docs/metadata-json-schema.md with complete field reference
  - All base fields for LoRA, Checkpoint, and Embedding models
  - Complete civitai object structure with Used vs Stored field classification
  - Model-level fields (allowCommercialUse, allowDerivatives, etc.)
  - Creator fields (username, image)
  - customImages structure with actual field names and types
  - Field behavior categories (Auto-Updated, Set Once, User-Editable)

- Add .specs/metadata.schema.json for programmatic validation
  - JSON Schema draft-07 format
  - oneOf schemas for each model type
  - Definitions for civitaiObject and usageTips

- Add example metadata files for each model type
  - lora-civitai.json: LoRA with full Civitai data
  - lora-custom.json: User-defined LoRA with trigger words
  - lora-no-triggerwords.json: LoRA without trigger words
  - checkpoint-civitai.json: Checkpoint from Civitai
  - embedding-custom.json: Custom embedding

Key clarifications:
  - modified: Import timestamp (Set Once, never changes after import)
  - size: File size at import time (Set Once)
  - base_model: Optional with actual values (SDXL 1.0, Flux.1 D, etc.)
  - model_type: Used in metadata.json (not sub_type which is internal)
  - allowCommercialUse: ["Image", "Video", "RentCivit", "Rent"]
  - civitai.files/images: Marked as Used by Lora Manager
  - User-editable fields clearly documented (model_name, tags, etc.)
2026-03-16 09:41:58 +08:00
Will Miao
ee466113d5 feat: implement batch import recipe functionality (frontend + backend fixes)
Backend fixes:
- Add missing API route for /api/lm/recipes/batch-import/progress (GET)
- Add missing API route for /api/lm/recipes/batch-import/directory (POST)
- Add missing API route for /api/lm/recipes/browse-directory (POST)
- Register WebSocket endpoint for batch import progress
- Fix skip_no_metadata default value (True -> False) to allow no-LoRA imports
- Add items array to BatchImportProgress.to_dict() for detailed results

Frontend implementation:
- Create BatchImportManager.js with complete batch import workflow
- Add directory browser UI for selecting folders
- Add batch import modal with URL list and directory input modes
- Implement real-time progress tracking (WebSocket + HTTP polling)
- Add results summary with success/failed/skipped statistics
- Add expandable details view showing individual item status
- Auto-refresh recipe list after import completion

UI improvements:
- Add spinner animation for importing status
- Simplify results summary UI to match progress stats styling
- Fix current item text alignment
- Fix dark theme styling for directory browser button
- Fix batch import button styling consistency

Translations:
- Add batch import related i18n keys to all locale files
- Run sync_translation_keys.py to sync all translations

Fixes:
- Batch import now allows images without LoRAs (matches single import behavior)
- Progress endpoint now returns complete items array with status details
- Results view correctly displays skipped items with error messages
2026-03-16 09:41:58 +08:00
Will Miao
f86651652c feat(batch-import): implement backend batch import service with adaptive concurrency
- Add BatchImportService with concurrent execution using asyncio.gather
- Implement AdaptiveConcurrencyController with dynamic adjustment
- Add input validation for URLs and local paths
- Support duplicate detection via skip_duplicates parameter
- Add WebSocket progress broadcasting for real-time updates
- Create comprehensive unit tests for batch import functionality
- Update API handlers and route registrations
- Add i18n translation keys for batch import UI
2026-03-16 09:41:58 +08:00
Will Miao
c89d4dae85 fix(extra-paths): support trigger words for LoRAs in extra folder paths, fixes #860
- Update get_lora_info() to check both loras_roots and extra_loras_roots
- Add fallback logic to return trigger words even if path not in recognized roots
- Ensure Trigger Word Toggle node displays trigger words for LoRAs from extra folder paths

Fixes issue where LoRAs added from extra folder paths would not show their trigger words in connected Trigger Word Toggle nodes.
2026-03-16 09:38:21 +08:00
pixelpaws
55a18d401b Merge pull request #858 from botchedchuckle/patch-1
Fix: Escape HTML in Prompt/NegativePrompt for MetadataPanel
2026-03-14 14:43:46 +08:00
botchedchuckle
7570936c75 Fix: Escape HTML in Prompt/NegativePrompt for MetadataPanel
* Fixed a bug where `prompt` and `negativePrompt` were both being
  added directly to HTML without escaping them. Given prompts are
  allowed to have HTML characters (e.g. `<lora:something:0.75>`), by
  forgetting to escape them some tags were missing in the metadata
  views for example images using those characters.
2026-03-13 01:29:04 -07:00
Will Miao
4fcf641d57 fix(bulk-context-menu): escape special characters in data-filepath selector to support double quotes in filenames (#845) 2026-03-12 08:49:10 +08:00
Will Miao
5c29e26c4e fix(top-menu): add backward compatibility for actionBarButtons API (#853)
- Implement version detection using __COMFYUI_FRONTEND_VERSION__ and /system_stats API
- Add version parsing and comparison utilities
- Dynamically register extension based on frontend version
- Use actionBarButtons API for frontend >= 1.33.9
- Fallback to legacy ComfyButton approach for older versions
- Add comprehensive version detection tests
2026-03-12 07:41:29 +08:00
Will Miao
ee765a6d22 fix(sidebar): escape folder names and paths to support double quotes
- Import and use escapeHtml and escapeAttribute in SidebarManager.js
- Escape data-path and title attributes in folder tree and breadcrumbs
- Use CSS.escape() for attribute selectors in updateTreeSelection
- Fixes issue #843 where folders with double quotes broke navigation
2026-03-11 23:33:11 +08:00
Will Miao
c02f603ed2 fix(autocomplete): add wheel event handler for canvas zoom support
Add @wheel event listener to AutocompleteTextWidget textarea to enable canvas zoom when textarea has no scrollbar.

The onWheel handler:
- Forwards pinch-to-zoom (ctrl+wheel) to canvas
- Passes horizontal scroll to canvas
- When textarea has vertical scrollbar: lets textarea scroll
- When textarea has NO scrollbar: forwards to canvas for zoom

Behavior now matches ComfyUI built-in multiline widget.

Fixes #850
2026-03-11 20:58:01 +08:00
Will Miao
ee84b30023 Fix node selector z-index issue in recipe modal
Change node-selector z-index from 1000 to var(--z-overlay) (2000)
to ensure the model selector UI appears above the recipe modal
when sending checkpoints to workflow with multiple targets.
2026-03-09 19:29:13 +08:00
Will Miao
97979d9e7c fix(send-to-workflow): strip file extension before searching relative paths
Backend _relative_path_matches_tokens() removes extensions from paths
before matching (commit 43f6bfab). This fix ensures frontend also
removes extensions from search terms to avoid matching failures.

Fixes issue where send model to workflow would receive absolute
paths instead of relative paths because the API returned empty
results when searching with file extension.
2026-03-09 15:49:37 +08:00
Will Miao
cda271890a feat(workflow-template): add new tab template workflow with auto-zoom
- Add GET /api/lm/example-workflows endpoint to list available templates
- Add GET /api/lm/example-workflows/{filename} to retrieve specific workflow
- Add 'New Tab Template Workflow' setting in LoRA Manager settings
- Automatically apply 80% zoom level when loading template workflows
- Override workflow's saved view settings to prevent visual zoom flicker

The feature allows users to select a template workflow from example_workflows/
directory to load when creating new workflow tabs, with a hardcoded 0.8 zoom
level for better initial view experience.
2026-03-08 21:03:14 +08:00
Will Miao
2fbe6c8843 fix(autocomplete): fix dropdown width calculation bug
Temporarily remove width constraints when measuring content to prevent
scrollWidth from being limited by narrow container. This fixes the issue
where dropdown width was incorrectly calculated as ~120px.

Also update test to match maxItems default value (100).
2026-03-07 23:23:26 +08:00
Will Miao
4fb07370dd fix(tests): add offset parameter to MockTagFTSIndex.search()
Add missing offset parameter to MockTagFTSIndex to support
pagination changes from commit a802a89.

- Update search() signature to include offset=0
- Implement pagination logic with offset/limit slicing
2026-03-07 23:10:00 +08:00
Will Miao
43f6bfab36 fix(autocomplete): strip file extensions from model names in search suggestions
Remove .safetensors/.ckpt/.pt/.bin extensions from model names in autocomplete
suggestions to improve UX and search relevance:

Frontend (web/comfyui/autocomplete.js):
- Add _getDisplayText() helper to strip extensions from model paths
- Update _matchItem() to match against filename without extension
- Update render() and createItemElement() to display clean names

Backend (py/services/base_model_service.py):
- Add _remove_model_extension() helper method
- Update _relative_path_matches_tokens() to ignore extensions in matching
- Update _relative_path_sort_key() to sort based on names without extensions

Tests (tests/services/test_relative_path_search.py):
- Add tests to verify 's' and 'safe' queries don't match all .safetensors files

Fixes issue where typing 's' would match all .safetensors files and cluttered
suggestions with redundant extension names.
2026-03-07 23:07:10 +08:00
Will Miao
a802a89ff9 feat(autocomplete): implement virtual scrolling and pagination
- Add virtual scrolling with configurable visible items (default: 15)
- Implement pagination with offset/limit for backend APIs
- Support loading more items on scroll
- Fix width calculation for suggestions dropdown
- Update backend services to support offset parameter

Files modified:
- web/comfyui/autocomplete.js (virtual scroll, pagination)
- py/services/base_model_service.py (offset support)
- py/services/custom_words_service.py (offset support)
- py/services/tag_fts_index.py (offset support)
- py/routes/handlers/model_handlers.py (offset param)
- py/routes/handlers/misc_handlers.py (offset param)
2026-03-07 22:17:26 +08:00
Will Miao
343dd91e4b feat(ui): improve clear button UX in autocomplete text widget
Move clear button from top-right to bottom-right to avoid

obscuring text content. Add hover visibility for cleaner UI.

Reserve bottom padding in textarea for button placement.
2026-03-07 21:09:59 +08:00
Will Miao
3756f88368 feat(autocomplete): improve multi-word tag search with query normalization
Implement search query variation generation to improve matching for multi-word tags:
- Generate multiple query forms: original, underscore (spaces->_), no-space, last token
- Execute up to 4 parallel queries with result merging and deduplication
- Add smart matching with symbol-insensitive comparison (blue hair matches blue_hair)
- Sort results with exact matches prioritized over partial matches

This allows users to type natural language queries like 'looking to the side' and
find tags like 'Looking_to_the_side' while maintaining backward compatibility
with continuous typing workflows.
2026-03-07 20:24:35 +08:00
Will Miao
acc625ead3 feat(recipes): add sync changes dropdown menu for recipe refresh
- Add syncChanges() function to recipeApi.js for quick refresh without cache rebuild
- Implement dropdown menu UI in recipes page with quick refresh and full rebuild options
- Add initDropdowns() method to RecipeManager for dropdown interaction handling
- Update AGENTS.md with more precise instruction about running sync_translation_keys.py
- Integrate sync changes functionality as default refresh behavior
2026-03-04 20:31:58 +08:00
Will Miao
f402505f97 i18n: complete TODO translations in locale files
- Add missing translations for modelTypes, recipe refresh, and sync notifications
- Translate for all supported languages (zh-CN, zh-TW, ja, ko, fr, de, es, ru, he)
- Run sync_translation_keys.py to ensure key consistency
2026-03-04 20:27:21 +08:00
Will Miao
4d8113464c perf(recipe_scanner): eliminate event loop blocking during cache rebuild
Refactor force_refresh path to use thread pool execution instead of blocking
the event loop shared with ComfyUI. Key changes:

- Fix 1: Route force_refresh through _initialize_recipe_cache_sync() in thread pool
- Fix 2: Add GIL release points (time.sleep(0)) every 100 files in sync loops
- Fix 3: Move RecipeCache.resort() to thread pool via run_in_executor
- Fix 4: Persist cache automatically after force_refresh
- Fix 5: Increase yield frequency in _enrich_cache_metadata (every recipe)

This eliminates the ~5 minute freeze when rebuilding 30K recipe cache.

Fixes performance issue where ComfyUI became unresponsive during recipe
scanning due to shared Python event loop blocking.
2026-03-04 15:10:46 +08:00
Will Miao
1ed503a6b5 docs: add lazy hash computation to v1.0.0 release notes 2026-03-04 07:41:19 +08:00
Will Miao
d67914e095 docs: update portable package download link to v1.0.0 2026-03-03 22:06:29 +08:00
Will Miao
2c810306fb feat: implement automated supporter recognition in README
- Add scripts/update_supporters.py to generate supporter list from JSON
- Set up GitHub Action to auto-update README.md on supporters.json change
- Update README.md with placeholders and personalized gratitude message
2026-03-03 21:52:08 +08:00
Will Miao
dd94c6b31a chore: add v1.0.0 release notes and update version in pyproject.toml 2026-03-03 21:19:50 +08:00
Will Miao
1a0edec712 feat: enhance supporters modal with auto-scrolling and visual improvements
- Add auto-scrolling functionality to supporters list with user interaction controls (pause on hover, manual scroll)
- Implement gradient overlays at top/bottom for credits-like appearance
- Style custom scrollbar with subtle hover effects for better UX
- Adjust padding and positioning to ensure all supporters remain visible during scroll
2026-03-03 21:18:12 +08:00
Will Miao
7ba9b998d3 fix(stats): resolve dashboard initialization race condition and test failure
- Refactor StatisticsManager to return promises from initializeVisualizations and initializeLists
- Update fetchAndRenderList to use the fetchData wrapper for consistent mocking
- Update statistics dashboard test to include mock data for paginated model-usage-list endpoint
2026-03-03 15:08:33 +08:00
Will Miao
8c5d5a8ca0 feat(stats): implement infinite scrolling and paginated model usage lists (fixes #812)
- Add get_model_usage_list API endpoint for paginated stats
- Replace static rendering with client-side infinite scroll logic
- Add scrollbars and max-height to model usage lists
2026-03-03 15:00:01 +08:00
Will Miao
672e4cff90 fix(move): reset manual folder selection when using default path (fixes #836) 2026-03-02 23:29:16 +08:00
Will Miao
c2716e3c39 fix(i18n): resolve missing translation keys and complete multi-language support
- Add missing keys 'common.cancel', 'common.confirm', and 'sidebar.dragDrop.noDragState' to en.json
- Synchronize all locale files using sync_translation_keys.py
- Complete translations for zh-CN, zh-TW, ja, ru, de, fr, es, ko, and he
- Implement sidebar drag-and-drop folder creation with visual feedback and input validation
- Optimize MoveManager to use resetAndReload for consistent UI state after moving models
- Fix recursive visibility check for root folder in MoveManager
2026-03-02 22:02:47 +08:00
Will Miao
b72cf7ba98 feat(showcase): optimize CivitAI media URLs for better performance
- Add CivitAI URL utility with optimization strategies for showcase and thumbnail modes
- Replace /original=true with /optimized=true for showcase videos to reduce bandwidth
- Remove redundant crossorigin and referrerpolicy attributes from video elements
- Use media type detection to apply appropriate optimization (image vs video)
- Integrate URL optimization into showcase rendering for improved loading times
2026-03-02 14:05:44 +08:00
Will Miao
bde11b153f fix(preview): resolve CORS error when setting CivitAI remote media as preview
- Add new endpoint POST /api/lm/{prefix}/set-preview-from-url to handle
  remote image downloads server-side, avoiding CORS issues
- Use rewrite_preview_url() to download optimized smaller images (450px width)
- Use Downloader service for reliable downloads with retry logic and proxy support
- Update frontend to call new endpoint instead of fetching images in browser

fixes #837
2026-03-02 13:21:18 +08:00
Will Miao
8b924b1551 feat: add draggable attribute to recipe card elements
- Set draggable=true on recipe card div elements to enable drag-and-drop functionality
- This allows users to drag recipe cards for reordering or other interactions
2026-03-02 10:28:36 +08:00
Will Miao
ce08935b1e fix(showcase): support middle-click and left-click to expand showcase
Fix showcase expansion to work with both left-click and middle-click (drag scroll).

Problem: The scroll-indicator click events were only bound when the carousel
was in expanded state. Initial collapsed state meant no click handlers were
attached, so clicking did nothing.

Solution:
- Extract scroll-indicator event binding into separate bindScrollIndicatorEvents()
- Call bindScrollIndicatorEvents() immediately when showcase loads, regardless
  of collapsed state
- Separate handlers for left-click (click event) and middle-click (mousedown
  event) to avoid double-triggering

Changes:
- Add bindScrollIndicatorEvents() function for early event binding
- Use click event for left mouse button (button 0)
- Use mousedown event for middle mouse button (button 1)
- Update loadExampleImages() to bind events immediately
- Update initShowcaseContent() to use the new function
2026-03-02 08:44:15 +08:00
Will Miao
24fcbeaf76 Skip performance tests by default
- Add 'performance' marker to pytest.ini
- Add pytestmark to test_cache_performance.py
- Use -m 'not performance' by default in addopts
- Allows manual execution with 'pytest -m performance'
2026-02-28 21:46:20 +08:00
Will Miao
c9e5ea42cb Fix null-safety issues and apply code formatting
Bug fixes:
- Add null guards for base_models_roots/embeddings_roots in backup cleanup
- Fix null-safety initialization of extra_unet_roots

Formatting:
- Apply consistent code style across Python files
- Fix line wrapping, quote consistency, and trailing commas
- Add type ignore comments for dynamic/platform-specific code
2026-02-28 21:38:41 +08:00
Will Miao
b005961ee5 feat(ui): improve changelog styling and spacing
- Remove left padding from changelog content container
- Add consistent padding to all changelog items
- Simplify latest changelog item styling by removing redundant padding
- Maintain visual distinction for latest items with background and border
2026-02-28 20:47:44 +08:00
Will Miao
ce03bbbc4e fix(frontend): defer LoadingManager DOM initialization to resolve i18n warning
Delay DOM creation in LoadingManager constructor to first use time,
ensuring window.i18n is ready before translate() is called.

This eliminates the 'i18n not available' console warning during
module initialization while maintaining correct translations
for cancel button and loading status text.
2026-02-28 20:30:16 +08:00
Will Miao
78b55d10ba refactor: move supporters loading to separate API endpoint
- Add SupportersHandler in misc_handlers.py to serve /api/lm/supporters
- Register new endpoint in misc_route_registrar.py
- Remove supporters from page load template context in model_handlers.py
- Create supportersService.js for frontend data fetching
- Update Header.js to fetch supporters when support modal opens
- Modify support_modal.html to use client-side rendering

This change improves page load performance by loading supporters data
on-demand instead of during initial page render.
2026-02-28 20:14:20 +08:00
Will Miao
77a2215e62 Fix lazy hash calculation for checkpoints in extra paths
- Allow empty sha256 when hash_status is 'pending' in cache entry validator
- Add on-demand hash calculation during bulk metadata refresh for checkpoints
  with pending hash status
- Add comprehensive tests for both fixes

Fixes issue where checkpoints in extra paths were not visible in UI and
not processed during bulk metadata refresh due to empty sha256.
2026-02-27 19:19:16 +08:00
pixelpaws
31901f1f0e Merge pull request #829 from willmiao/feature/lazy-hash-checkpoints
feat: lazy hash calculation for checkpoints
2026-02-27 11:02:39 +08:00
Will Miao
12a789ef96 fix(extra-folder-paths): fix extra folder paths support for checkpoint and unet roots
- Fix config.py: save and restore main paths when processing extra folder paths to prevent
  _prepare_checkpoint_paths from overwriting checkpoints_roots and unet_roots
- Fix lora_manager.py: apply library settings during initialization to load extra folder paths
  in ComfyUI plugin mode
- Fix checkpoint_routes.py: merge checkpoints/unet roots with extra paths in API endpoints
- Add logging for extra folder paths

Fixes issue where extra folder paths were not recognized for checkpoints and unet models.
2026-02-27 10:37:15 +08:00
Will Miao
d50bbe71c2 fix(extra-folder-paths): fix extra folder paths support for checkpoint and unet roots
- Fix config.py: save and restore main paths when processing extra folder paths to prevent
  _prepare_checkpoint_paths from overwriting checkpoints_roots and unet_roots
- Fix lora_manager.py: apply library settings during initialization to load extra folder paths
  in ComfyUI plugin mode
- Fix checkpoint_routes.py: merge checkpoints/unet roots with extra paths in API endpoints
- Add logging for extra folder paths

Fixes issue where extra folder paths were not recognized for checkpoints and unet models.
2026-02-27 10:27:29 +08:00
106 changed files with 14659 additions and 2644 deletions

View 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

31
.github/workflows/update-supporters.yml vendored Normal file
View File

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

464
.specs/metadata.schema.json Normal file
View 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
}
}
}

View File

@@ -135,7 +135,7 @@ npm run test:coverage # Generate coverage report
- ALWAYS use English for comments (per copilot-instructions.md) - ALWAYS use English for comments (per copilot-instructions.md)
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json) - Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"` - Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
- Run `python scripts/sync_translation_keys.py` after UI string updates - Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
- Symlinks require normalized paths - Symlinks require normalized paths
## Frontend UI Architecture ## Frontend UI Architecture

File diff suppressed because one or more lines are too long

627
data/supporters.json Normal file
View File

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

View File

@@ -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

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"actions": { "actions": {
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"confirm": "Bestätigen",
"delete": "Löschen", "delete": "Löschen",
"move": "Verschieben", "move": "Verschieben",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
@@ -11,7 +14,8 @@
"backToTop": "Nach oben", "backToTop": "Nach oben",
"settings": "Einstellungen", "settings": "Einstellungen",
"help": "Hilfe", "help": "Hilfe",
"add": "Hinzufügen" "add": "Hinzufügen",
"close": "Schließen"
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Voreinstellungsname...", "presetNamePlaceholder": "Voreinstellungsname...",
"baseModel": "Basis-Modell", "baseModel": "Basis-Modell",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Modelltypen",
"license": "Lizenz", "license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"noDefault": "Kein Standard" "noDefault": "Kein Standard"
}, },
"extraFolderPaths": {
"title": "Zusätzliche Ordnerpfade",
"help": "Fügen Sie zusätzliche Modellordner außerhalb der Standardpfade von ComfyUI hinzu. Diese Pfade werden separat gespeichert und zusammen mit den Standardordnern gescannt.",
"description": "Konfigurieren Sie zusätzliche Ordner zum Scannen von Modellen. Diese Pfade sind spezifisch für LoRA Manager und werden mit den Standardpfaden von ComfyUI zusammengeführt.",
"modelTypes": {
"lora": "LoRA-Pfade",
"checkpoint": "Checkpoint-Pfade",
"unet": "Diffusionsmodell-Pfade",
"embedding": "Embedding-Pfade"
},
"pathPlaceholder": "/pfad/zu/extra/modellen",
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert.",
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
}
},
"priorityTags": { "priorityTags": {
"title": "Prioritäts-Tags", "title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))", "description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
@@ -485,23 +506,6 @@
"proxyPassword": "Passwort (optional)", "proxyPassword": "Passwort (optional)",
"proxyPasswordPlaceholder": "passwort", "proxyPasswordPlaceholder": "passwort",
"proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)" "proxyPasswordHelp": "Passwort für die Proxy-Authentifizierung (falls erforderlich)"
},
"extraFolderPaths": {
"title": "Zusätzliche Ordnerpfade",
"help": "Fügen Sie zusätzliche Modellordner außerhalb der Standardpfade von ComfyUI hinzu. Diese Pfade werden separat gespeichert und zusammen mit den Standardordnern gescannt.",
"description": "Konfigurieren Sie zusätzliche Ordner zum Scannen von Modellen. Diese Pfade sind spezifisch für LoRA Manager und werden mit den Standardpfaden von ComfyUI zusammengeführt.",
"modelTypes": {
"lora": "LoRA-Pfade",
"checkpoint": "Checkpoint-Pfade",
"unet": "Diffusionsmodell-Pfade",
"embedding": "Embedding-Pfade"
},
"pathPlaceholder": "/pfad/zu/extra/modellen",
"saveSuccess": "Zusätzliche Ordnerpfade aktualisiert.",
"saveError": "Fehler beim Aktualisieren der zusätzlichen Ordnerpfade: {message}",
"validation": {
"duplicatePath": "Dieser Pfad ist bereits konfiguriert"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "Wenigste" "lorasCountAsc": "Wenigste"
}, },
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren" "title": "Rezeptliste aktualisieren",
"quick": "Änderungen synchronisieren",
"quickTooltip": "Änderungen synchronisieren - schnelle Aktualisierung ohne Cache-Neubau",
"full": "Cache neu aufbauen",
"fullTooltip": "Cache neu aufbauen - vollständiger Rescan aller Rezeptdateien"
}, },
"filteredByLora": "Gefiltert nach LoRA", "filteredByLora": "Gefiltert nach LoRA",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "Rezept-Reparatur fehlgeschlagen: {message}", "failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID" "missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden"
},
"empty": {
"noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "WeChat QR-Code anzeigen", "showWechatQR": "WeChat QR-Code anzeigen",
"hideWechatQR": "WeChat QR-Code ausblenden" "hideWechatQR": "WeChat QR-Code ausblenden"
}, },
"footer": "Vielen Dank, dass Sie LoRA Manager verwenden! ❤️" "footer": "Vielen Dank, dass Sie LoRA Manager verwenden! ❤️",
"supporters": {
"title": "Danke an alle Unterstützer",
"subtitle": "Danke an {count} Unterstützer, die dieses Projekt möglich gemacht haben",
"specialThanks": "Besonderer Dank",
"allSupporters": "Alle Unterstützer",
"totalCount": "{count} Unterstützer insgesamt"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "Fehler beim Laden der {modelType}s: {message}", "loadFailed": "Fehler beim Laden der {modelType}s: {message}",
"refreshComplete": "Aktualisierung abgeschlossen", "refreshComplete": "Aktualisierung abgeschlossen",
"refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}", "refreshFailed": "Fehler beim Aktualisieren der Rezepte: {message}",
"syncComplete": "Synchronisation abgeschlossen",
"syncFailed": "Fehler beim Synchronisieren der Rezepte: {message}",
"updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}", "updateFailed": "Fehler beim Aktualisieren des Rezepts: {error}",
"updateError": "Fehler beim Aktualisieren des Rezepts: {message}", "updateError": "Fehler beim Aktualisieren des Rezepts: {message}",
"nameSaved": "Rezept \"{name}\" erfolgreich gespeichert", "nameSaved": "Rezept \"{name}\" erfolgreich gespeichert",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}", "recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
"importFailed": "Import fehlgeschlagen: {message}", "importFailed": "Import fehlgeschlagen: {message}",
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums", "folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums" "folderTreeError": "Fehler beim Laden des Ordnerbaums",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "Keine Modelle ausgewählt", "noModelsSelected": "Keine Modelle ausgewählt",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Cancel",
"confirm": "Confirm",
"actions": { "actions": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete", "delete": "Delete",
"move": "Move", "move": "Move",
"refresh": "Refresh", "refresh": "Refresh",
@@ -11,7 +14,8 @@
"backToTop": "Back to top", "backToTop": "Back to top",
"settings": "Settings", "settings": "Settings",
"help": "Help", "help": "Help",
"add": "Add" "add": "Add",
"close": "Close"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
@@ -682,7 +686,11 @@
"lorasCountAsc": "Least" "lorasCountAsc": "Least"
}, },
"refresh": { "refresh": {
"title": "Refresh recipe list" "title": "Refresh recipe list",
"quick": "Sync Changes",
"quickTooltip": "Sync changes - quick refresh without rebuilding cache",
"full": "Rebuild Cache",
"fullTooltip": "Rebuild cache - full rescan of all recipe files"
}, },
"filteredByLora": "Filtered by LoRA", "filteredByLora": "Filtered by LoRA",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "Failed to repair recipe: {message}", "failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID" "missingId": "Cannot repair recipe: Missing recipe ID"
} }
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "Not available in list view", "collapseAllDisabled": "Not available in list view",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.", "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found"
},
"empty": {
"noFolders": "No folders found",
"dragHint": "Drag items here to create folders"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "Show WeChat QR Code", "showWechatQR": "Show WeChat QR Code",
"hideWechatQR": "Hide WeChat QR Code" "hideWechatQR": "Hide WeChat QR Code"
}, },
"footer": "Thank you for using LoRA Manager! ❤️" "footer": "Thank you for using LoRA Manager! ❤️",
"supporters": {
"title": "Thank You To Our Supporters",
"subtitle": "Thanks to {count} supporters who made this project possible",
"specialThanks": "Special Thanks",
"allSupporters": "All Supporters",
"totalCount": "{count} supporters in total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "Failed to load {modelType}s: {message}", "loadFailed": "Failed to load {modelType}s: {message}",
"refreshComplete": "Refresh complete", "refreshComplete": "Refresh complete",
"refreshFailed": "Failed to refresh recipes: {message}", "refreshFailed": "Failed to refresh recipes: {message}",
"syncComplete": "Sync complete",
"syncFailed": "Failed to sync recipes: {message}",
"updateFailed": "Failed to update recipe: {error}", "updateFailed": "Failed to update recipe: {error}",
"updateError": "Error updating recipe: {message}", "updateError": "Error updating recipe: {message}",
"nameSaved": "Recipe \"{name}\" saved successfully", "nameSaved": "Recipe \"{name}\" saved successfully",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "Failed to save recipe: {error}", "recipeSaveFailed": "Failed to save recipe: {error}",
"importFailed": "Import failed: {message}", "importFailed": "Import failed: {message}",
"folderTreeFailed": "Failed to load folder tree", "folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree" "folderTreeError": "Error loading folder tree",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "No models selected", "noModelsSelected": "No models selected",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"actions": { "actions": {
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Confirmar",
"delete": "Eliminar", "delete": "Eliminar",
"move": "Mover", "move": "Mover",
"refresh": "Actualizar", "refresh": "Actualizar",
@@ -11,7 +14,8 @@
"backToTop": "Volver arriba", "backToTop": "Volver arriba",
"settings": "Configuración", "settings": "Configuración",
"help": "Ayuda", "help": "Ayuda",
"add": "Añadir" "add": "Añadir",
"close": "Cerrar"
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Nombre del preajuste...", "presetNamePlaceholder": "Nombre del preajuste...",
"baseModel": "Modelo base", "baseModel": "Modelo base",
"modelTags": "Etiquetas (Top 20)", "modelTags": "Etiquetas (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Tipos de modelos",
"license": "Licencia", "license": "Licencia",
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"noDefault": "Sin predeterminado" "noDefault": "Sin predeterminado"
}, },
"extraFolderPaths": {
"title": "Rutas de carpetas adicionales",
"help": "Agregue carpetas de modelos adicionales fuera de las rutas estándar de ComfyUI. Estas rutas se almacenan por separado y se escanean junto con las carpetas predeterminadas.",
"description": "Configure carpetas adicionales para escanear modelos. Estas rutas son específicas de LoRA Manager y se fusionarán con las rutas predeterminadas de ComfyUI.",
"modelTypes": {
"lora": "Rutas de LoRA",
"checkpoint": "Rutas de Checkpoint",
"unet": "Rutas de modelo de difusión",
"embedding": "Rutas de Embedding"
},
"pathPlaceholder": "/ruta/a/modelos/extra",
"saveSuccess": "Rutas de carpetas adicionales actualizadas.",
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": {
"duplicatePath": "Esta ruta ya está configurada"
}
},
"priorityTags": { "priorityTags": {
"title": "Etiquetas prioritarias", "title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))", "description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
@@ -485,23 +506,6 @@
"proxyPassword": "Contraseña (opcional)", "proxyPassword": "Contraseña (opcional)",
"proxyPasswordPlaceholder": "contraseña", "proxyPasswordPlaceholder": "contraseña",
"proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)" "proxyPasswordHelp": "Contraseña para autenticación de proxy (si es necesario)"
},
"extraFolderPaths": {
"title": "Rutas de carpetas adicionales",
"help": "Agregue carpetas de modelos adicionales fuera de las rutas estándar de ComfyUI. Estas rutas se almacenan por separado y se escanean junto con las carpetas predeterminadas.",
"description": "Configure carpetas adicionales para escanear modelos. Estas rutas son específicas de LoRA Manager y se fusionarán con las rutas predeterminadas de ComfyUI.",
"modelTypes": {
"lora": "Rutas de LoRA",
"checkpoint": "Rutas de Checkpoint",
"unet": "Rutas de modelo de difusión",
"embedding": "Rutas de Embedding"
},
"pathPlaceholder": "/ruta/a/modelos/extra",
"saveSuccess": "Rutas de carpetas adicionales actualizadas.",
"saveError": "Error al actualizar las rutas de carpetas adicionales: {message}",
"validation": {
"duplicatePath": "Esta ruta ya está configurada"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "Menos" "lorasCountAsc": "Menos"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de recetas" "title": "Actualizar lista de recetas",
"quick": "Sincronizar cambios",
"quickTooltip": "Sincronizar cambios - actualización rápida sin reconstruir caché",
"full": "Reconstruir caché",
"fullTooltip": "Reconstruir caché - reescaneo completo de todos los archivos de recetas"
}, },
"filteredByLora": "Filtrado por LoRA", "filteredByLora": "Filtrado por LoRA",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "Error al reparar la receta: {message}", "failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta" "missingId": "No se puede reparar la receta: falta el ID de la receta"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "No disponible en vista de lista", "collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente"
},
"empty": {
"noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "Mostrar código QR de WeChat", "showWechatQR": "Mostrar código QR de WeChat",
"hideWechatQR": "Ocultar código QR de WeChat" "hideWechatQR": "Ocultar código QR de WeChat"
}, },
"footer": "¡Gracias por usar el gestor de LoRA! ❤️" "footer": "¡Gracias por usar el gestor de LoRA! ❤️",
"supporters": {
"title": "Gracias a todos los seguidores",
"subtitle": "Gracias a {count} seguidores que hicieron este proyecto posible",
"specialThanks": "Agradecimientos especiales",
"allSupporters": "Todos los seguidores",
"totalCount": "{count} seguidores en total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "Error al cargar {modelType}s: {message}", "loadFailed": "Error al cargar {modelType}s: {message}",
"refreshComplete": "Actualización completa", "refreshComplete": "Actualización completa",
"refreshFailed": "Error al actualizar recetas: {message}", "refreshFailed": "Error al actualizar recetas: {message}",
"syncComplete": "Sincronización completa",
"syncFailed": "Error al sincronizar recetas: {message}",
"updateFailed": "Error al actualizar receta: {error}", "updateFailed": "Error al actualizar receta: {error}",
"updateError": "Error actualizando receta: {message}", "updateError": "Error actualizando receta: {message}",
"nameSaved": "Receta \"{name}\" guardada exitosamente", "nameSaved": "Receta \"{name}\" guardada exitosamente",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "Error al guardar receta: {error}", "recipeSaveFailed": "Error al guardar receta: {error}",
"importFailed": "Importación falló: {message}", "importFailed": "Importación falló: {message}",
"folderTreeFailed": "Error al cargar árbol de carpetas", "folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error cargando árbol de carpetas" "folderTreeError": "Error cargando árbol de carpetas",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "No hay modelos seleccionados", "noModelsSelected": "No hay modelos seleccionados",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Annuler",
"confirm": "Confirmer",
"actions": { "actions": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Confirmer",
"delete": "Supprimer", "delete": "Supprimer",
"move": "Déplacer", "move": "Déplacer",
"refresh": "Actualiser", "refresh": "Actualiser",
@@ -11,7 +14,8 @@
"backToTop": "Retour en haut", "backToTop": "Retour en haut",
"settings": "Paramètres", "settings": "Paramètres",
"help": "Aide", "help": "Aide",
"add": "Ajouter" "add": "Ajouter",
"close": "Fermer"
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Nom du préréglage...", "presetNamePlaceholder": "Nom du préréglage...",
"baseModel": "Modèle de base", "baseModel": "Modèle de base",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Types de modèles",
"license": "Licence", "license": "Licence",
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "allowSellingGeneratedContent": "Vente autorisée",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
"noDefault": "Aucun par défaut" "noDefault": "Aucun par défaut"
}, },
"extraFolderPaths": {
"title": "Chemins de dossiers supplémentaires",
"help": "Ajoutez des dossiers de modèles supplémentaires en dehors des chemins standard de ComfyUI. Ces chemins sont stockés séparément et analysés aux côtés des dossiers par défaut.",
"description": "Configurez des dossiers supplémentaires pour l'analyse de modèles. Ces chemins sont spécifiques à LoRA Manager et seront fusionnés avec les chemins par défaut de ComfyUI.",
"modelTypes": {
"lora": "Chemins LoRA",
"checkpoint": "Chemins Checkpoint",
"unet": "Chemins de modèle de diffusion",
"embedding": "Chemins Embedding"
},
"pathPlaceholder": "/chemin/vers/modèles/supplémentaires",
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour.",
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": {
"duplicatePath": "Ce chemin est déjà configuré"
}
},
"priorityTags": { "priorityTags": {
"title": "Étiquettes prioritaires", "title": "Étiquettes prioritaires",
"description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", "description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))",
@@ -485,23 +506,6 @@
"proxyPassword": "Mot de passe (optionnel)", "proxyPassword": "Mot de passe (optionnel)",
"proxyPasswordPlaceholder": "mot_de_passe", "proxyPasswordPlaceholder": "mot_de_passe",
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)" "proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
},
"extraFolderPaths": {
"title": "Chemins de dossiers supplémentaires",
"help": "Ajoutez des dossiers de modèles supplémentaires en dehors des chemins standard de ComfyUI. Ces chemins sont stockés séparément et analysés aux côtés des dossiers par défaut.",
"description": "Configurez des dossiers supplémentaires pour l'analyse de modèles. Ces chemins sont spécifiques à LoRA Manager et seront fusionnés avec les chemins par défaut de ComfyUI.",
"modelTypes": {
"lora": "Chemins LoRA",
"checkpoint": "Chemins Checkpoint",
"unet": "Chemins de modèle de diffusion",
"embedding": "Chemins Embedding"
},
"pathPlaceholder": "/chemin/vers/modèles/supplémentaires",
"saveSuccess": "Chemins de dossiers supplémentaires mis à jour.",
"saveError": "Échec de la mise à jour des chemins de dossiers supplémentaires: {message}",
"validation": {
"duplicatePath": "Ce chemin est déjà configuré"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "Moins" "lorasCountAsc": "Moins"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes" "title": "Actualiser la liste des recipes",
"quick": "Synchroniser les changements",
"quickTooltip": "Synchroniser les changements - actualisation rapide sans reconstruire le cache",
"full": "Reconstruire le cache",
"fullTooltip": "Reconstruire le cache - rescan complet de tous les fichiers de recipes"
}, },
"filteredByLora": "Filtré par LoRA", "filteredByLora": "Filtré par LoRA",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "Échec de la réparation de la recette : {message}", "failed": "Échec de la réparation de la recette : {message}",
"missingId": "Impossible de réparer la recette : ID de recette manquant" "missingId": "Impossible de réparer la recette : ID de recette manquant"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "Non disponible en vue liste", "collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.",
"createFolderHint": "Relâcher pour créer un nouveau dossier",
"newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée"
},
"empty": {
"noFolders": "Aucun dossier trouvé",
"dragHint": "Faites glisser des éléments ici pour créer des dossiers"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "Afficher le QR Code WeChat", "showWechatQR": "Afficher le QR Code WeChat",
"hideWechatQR": "Masquer le QR Code WeChat" "hideWechatQR": "Masquer le QR Code WeChat"
}, },
"footer": "Merci d'utiliser le Gestionnaire LoRA ! ❤️" "footer": "Merci d'utiliser le Gestionnaire LoRA ! ❤️",
"supporters": {
"title": "Merci à tous les supporters",
"subtitle": "Merci aux {count} supporters qui ont rendu ce projet possible",
"specialThanks": "Remerciements spéciaux",
"allSupporters": "Tous les supporters",
"totalCount": "{count} supporters au total"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "Échec du chargement des {modelType}s : {message}", "loadFailed": "Échec du chargement des {modelType}s : {message}",
"refreshComplete": "Actualisation terminée", "refreshComplete": "Actualisation terminée",
"refreshFailed": "Échec de l'actualisation des recipes : {message}", "refreshFailed": "Échec de l'actualisation des recipes : {message}",
"syncComplete": "Synchronisation terminée",
"syncFailed": "Échec de la synchronisation des recipes : {message}",
"updateFailed": "Échec de la mise à jour de la recipe : {error}", "updateFailed": "Échec de la mise à jour de la recipe : {error}",
"updateError": "Erreur lors de la mise à jour de la recipe : {message}", "updateError": "Erreur lors de la mise à jour de la recipe : {message}",
"nameSaved": "Recipe \"{name}\" sauvegardée avec succès", "nameSaved": "Recipe \"{name}\" sauvegardée avec succès",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}", "recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
"importFailed": "Échec de l'importation : {message}", "importFailed": "Échec de l'importation : {message}",
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers", "folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers" "folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "Aucun modèle sélectionné", "noModelsSelected": "Aucun modèle sélectionné",

View File

@@ -1,17 +1,21 @@
{ {
"common": { "common": {
"cancel": "ביטול",
"confirm": "אישור",
"actions": { "actions": {
"save": "שמור", "save": "שמירה",
"cancel": "ביטול", "cancel": "ביטול",
"delete": "מחק", "confirm": "אישור",
"move": עבר", "delete": "מחיקה",
"refresh": "רענן", "move": "העברה",
"back": "חזור", "refresh": ענון",
"back": "חזרה",
"next": "הבא", "next": "הבא",
"backToTop": "חזור למעלה", "backToTop": "חזרה למעלה",
"settings": "הגדרות", "settings": "הגדרות",
"help": "עזרה", "help": "עזרה",
"add": "הוסף" "add": "הוספה",
"close": "סגור"
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "שם קביעה מראש...", "presetNamePlaceholder": "שם קביעה מראש...",
"baseModel": "מודל בסיס", "baseModel": "מודל בסיס",
"modelTags": "תגיות (20 המובילות)", "modelTags": "תגיות (20 המובילות)",
"modelTypes": "Model Types", "modelTypes": "סוגי מודלים",
"license": "רישיון", "license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"noDefault": "אין ברירת מחדל" "noDefault": "אין ברירת מחדל"
}, },
"extraFolderPaths": {
"title": "נתיבי תיקיות נוספים",
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.",
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.",
"modelTypes": {
"lora": "נתיבי LoRA",
"checkpoint": "נתיבי Checkpoint",
"unet": "נתיבי מודל דיפוזיה",
"embedding": "נתיבי Embedding"
},
"pathPlaceholder": "/נתיב/למודלים/נוספים",
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": {
"duplicatePath": "נתיב זה כבר מוגדר"
}
},
"priorityTags": { "priorityTags": {
"title": "תגיות עדיפות", "title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))", "description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
@@ -485,23 +506,6 @@
"proxyPassword": "סיסמה (אופציונלי)", "proxyPassword": "סיסמה (אופציונלי)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)" "proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
},
"extraFolderPaths": {
"title": "נתיבי תיקיות נוספים",
"help": "הוסף תיקיות מודלים נוספות מחוץ לנתיבים הסטנדרטיים של ComfyUI. נתיבים אלה נשמרים בנפרד ונסרקים לצד תיקיות ברירת המחדל.",
"description": "הגדר תיקיות נוספות לסריקת מודלים. נתיבים אלה ספציפיים ל-LoRA Manager וימוזגו עם נתיבי ברירת המחדל של ComfyUI.",
"modelTypes": {
"lora": "נתיבי LoRA",
"checkpoint": "נתיבי Checkpoint",
"unet": "נתיבי מודל דיפוזיה",
"embedding": "נתיבי Embedding"
},
"pathPlaceholder": "/נתיב/למודלים/נוספים",
"saveSuccess": "נתיבי תיקיות נוספים עודכנו.",
"saveError": "נכשל בעדכון נתיבי תיקיות נוספים: {message}",
"validation": {
"duplicatePath": "נתיב זה כבר מוגדר"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "הכי פחות" "lorasCountAsc": "הכי פחות"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים" "title": "רענן רשימת מתכונים",
"quick": "סנכרן שינויים",
"quickTooltip": "סנכרן שינויים - רענון מהיר ללא בניית מטמון מחדש",
"full": "בנה מטמון מחדש",
"fullTooltip": "בנה מטמון מחדש - סריקה מחדש מלאה של כל קבצי המתכונים"
}, },
"filteredByLora": "מסונן לפי LoRA", "filteredByLora": "מסונן לפי LoRA",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "תיקון המתכון נכשל: {message}", "failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון" "missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה"
},
"empty": {
"noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "הצג קוד QR של WeChat", "showWechatQR": "הצג קוד QR של WeChat",
"hideWechatQR": "הסתר קוד QR של WeChat" "hideWechatQR": "הסתר קוד QR של WeChat"
}, },
"footer": "תודה על השימוש במנהל LoRA! ❤️" "footer": "תודה על השימוש במנהל LoRA! ❤️",
"supporters": {
"title": "תודה לכל התומכים",
"subtitle": "תודה ל־{count} תומכים שהפכו את הפרויקט הזה לאפשרי",
"specialThanks": "תודה מיוחדת",
"allSupporters": "כל התומכים",
"totalCount": "{count} תומכים בסך הכל"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "טעינת {modelType}s נכשלה: {message}", "loadFailed": "טעינת {modelType}s נכשלה: {message}",
"refreshComplete": "הרענון הושלם", "refreshComplete": "הרענון הושלם",
"refreshFailed": "רענון המתכונים נכשל: {message}", "refreshFailed": "רענון המתכונים נכשל: {message}",
"syncComplete": "הסנכרון הושלם",
"syncFailed": "סנכרון המתכונים נכשל: {message}",
"updateFailed": "עדכון המתכון נכשל: {error}", "updateFailed": "עדכון המתכון נכשל: {error}",
"updateError": "שגיאה בעדכון המתכון: {message}", "updateError": "שגיאה בעדכון המתכון: {message}",
"nameSaved": "המתכון \"{name}\" נשמר בהצלחה", "nameSaved": "המתכון \"{name}\" נשמר בהצלחה",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}", "recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
"importFailed": "הייבוא נכשל: {message}", "importFailed": "הייבוא נכשל: {message}",
"folderTreeFailed": "טעינת עץ התיקיות נכשלה", "folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות" "folderTreeError": "שגיאה בטעינת עץ התיקיות",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "לא נבחרו מודלים", "noModelsSelected": "לא נבחרו מודלים",

View File

@@ -1,17 +1,21 @@
{ {
"common": { "common": {
"cancel": "キャンセル",
"confirm": "確認",
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "キャンセル", "cancel": "キャンセル",
"confirm": "確認",
"delete": "削除", "delete": "削除",
"move": "移動", "move": "移動",
"refresh": "更新", "refresh": "更新",
"back": "戻る", "back": "戻る",
"next": "次へ", "next": "次へ",
"backToTop": "トップ戻る", "backToTop": "トップ戻る",
"settings": "設定", "settings": "設定",
"help": "ヘルプ", "help": "ヘルプ",
"add": "追加" "add": "追加",
"close": "閉じる"
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "プリセット名...", "presetNamePlaceholder": "プリセット名...",
"baseModel": "ベースモデル", "baseModel": "ベースモデル",
"modelTags": "タグ上位20", "modelTags": "タグ上位20",
"modelTypes": "Model Types", "modelTypes": "モデルタイプ",
"license": "ライセンス", "license": "ライセンス",
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
"noDefault": "デフォルトなし" "noDefault": "デフォルトなし"
}, },
"extraFolderPaths": {
"title": "追加フォルダーパス",
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。",
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。",
"modelTypes": {
"lora": "LoRAパス",
"checkpoint": "Checkpointパス",
"unet": "Diffusionモデルパス",
"embedding": "Embeddingパス"
},
"pathPlaceholder": "/追加モデルへのパス",
"saveSuccess": "追加フォルダーパスを更新しました。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": {
"duplicatePath": "このパスはすでに設定されています"
}
},
"priorityTags": { "priorityTags": {
"title": "優先タグ", "title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
@@ -485,23 +506,6 @@
"proxyPassword": "パスワード(任意)", "proxyPassword": "パスワード(任意)",
"proxyPasswordPlaceholder": "パスワード", "proxyPasswordPlaceholder": "パスワード",
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)" "proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
},
"extraFolderPaths": {
"title": "追加フォルダーパス",
"help": "ComfyUIの標準パスの外部に追加のモデルフォルダを追加します。これらのパスは別々に保存され、デフォルトのフォルダと一緒にスキャンされます。",
"description": "モデルをスキャンするための追加フォルダを設定します。これらのパスはLoRA Manager固有であり、ComfyUIのデフォルトパスとマージされます。",
"modelTypes": {
"lora": "LoRAパス",
"checkpoint": "Checkpointパス",
"unet": "Diffusionモデルパス",
"embedding": "Embeddingパス"
},
"pathPlaceholder": "/追加モデルへのパス",
"saveSuccess": "追加フォルダーパスを更新しました。",
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": {
"duplicatePath": "このパスはすでに設定されています"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "少ない順" "lorasCountAsc": "少ない順"
}, },
"refresh": { "refresh": {
"title": "レシピリストを更新" "title": "レシピリストを更新",
"quick": "変更を同期",
"quickTooltip": "変更を同期 - キャッシュを再構築せずにクイック更新",
"full": "キャッシュを再構築",
"fullTooltip": "キャッシュを再構築 - すべてのレシピファイルを完全に再スキャン"
}, },
"filteredByLora": "LoRAでフィルタ済み", "filteredByLora": "LoRAでフィルタ済み",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "レシピの修復に失敗しました: {message}", "failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません" "missingId": "レシピを修復できません: レシピIDがありません"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。", "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません"
},
"empty": {
"noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "WeChat QRコードを表示", "showWechatQR": "WeChat QRコードを表示",
"hideWechatQR": "WeChat QRコードを非表示" "hideWechatQR": "WeChat QRコードを非表示"
}, },
"footer": "LoRA Managerをご利用いただきありがとうございます ❤️" "footer": "LoRA Managerをご利用いただきありがとうございます ❤️",
"supporters": {
"title": "サポーターの皆様に感謝",
"subtitle": "{count} 名のサポーターの皆様に、このプロジェクトを実現していただきありがとうございます",
"specialThanks": "特別感謝",
"allSupporters": "全サポーター",
"totalCount": "サポーター {count} 名"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "{modelType}の読み込みに失敗しました:{message}", "loadFailed": "{modelType}の読み込みに失敗しました:{message}",
"refreshComplete": "更新完了", "refreshComplete": "更新完了",
"refreshFailed": "レシピの更新に失敗しました:{message}", "refreshFailed": "レシピの更新に失敗しました:{message}",
"syncComplete": "同期完了",
"syncFailed": "レシピの同期に失敗しました:{message}",
"updateFailed": "レシピの更新に失敗しました:{error}", "updateFailed": "レシピの更新に失敗しました:{error}",
"updateError": "レシピ更新エラー:{message}", "updateError": "レシピ更新エラー:{message}",
"nameSaved": "レシピ\"{name}\"が正常に保存されました", "nameSaved": "レシピ\"{name}\"が正常に保存されました",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "レシピの保存に失敗しました:{error}", "recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"importFailed": "インポートに失敗しました:{message}", "importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました", "folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー" "folderTreeError": "フォルダツリー読み込みエラー",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "モデルが選択されていません", "noModelsSelected": "モデルが選択されていません",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "취소",
"confirm": "확인",
"actions": { "actions": {
"save": "저장", "save": "저장",
"cancel": "취소", "cancel": "취소",
"confirm": "확인",
"delete": "삭제", "delete": "삭제",
"move": "이동", "move": "이동",
"refresh": "새로고침", "refresh": "새로고침",
@@ -11,7 +14,8 @@
"backToTop": "맨 위로", "backToTop": "맨 위로",
"settings": "설정", "settings": "설정",
"help": "도움말", "help": "도움말",
"add": "추가" "add": "추가",
"close": "닫기"
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "프리셋 이름...", "presetNamePlaceholder": "프리셋 이름...",
"baseModel": "베이스 모델", "baseModel": "베이스 모델",
"modelTags": "태그 (상위 20개)", "modelTags": "태그 (상위 20개)",
"modelTypes": "Model Types", "modelTypes": "모델 유형",
"license": "라이선스", "license": "라이선스",
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
"noDefault": "기본값 없음" "noDefault": "기본값 없음"
}, },
"extraFolderPaths": {
"title": "추가 폴다 경로",
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.",
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.",
"modelTypes": {
"lora": "LoRA 경로",
"checkpoint": "Checkpoint 경로",
"unet": "Diffusion 모델 경로",
"embedding": "Embedding 경로"
},
"pathPlaceholder": "/추가/모델/경로",
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
}
},
"priorityTags": { "priorityTags": {
"title": "우선순위 태그", "title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
@@ -485,23 +506,6 @@
"proxyPassword": "비밀번호 (선택사항)", "proxyPassword": "비밀번호 (선택사항)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)" "proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
},
"extraFolderPaths": {
"title": "추가 폴다 경로",
"help": "ComfyUI의 표준 경로 외부에 추가 모델 폴드를 추가하세요. 이러한 경로는 별도로 저장되며 기본 폴와 함께 스캔됩니다.",
"description": "모델을 스캔하기 위한 추가 폴를 설정하세요. 이러한 경로는 LoRA Manager 특유의 것이며 ComfyUI의 기본 경로와 병합됩니다.",
"modelTypes": {
"lora": "LoRA 경로",
"checkpoint": "Checkpoint 경로",
"unet": "Diffusion 모델 경로",
"embedding": "Embedding 경로"
},
"pathPlaceholder": "/추가/모델/경로",
"saveSuccess": "추가 폴다 경로가 업데이트되었습니다.",
"saveError": "추가 폴다 경로 업데이트 실패: {message}",
"validation": {
"duplicatePath": "이 경로는 이미 구성되어 있습니다"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "적은순" "lorasCountAsc": "적은순"
}, },
"refresh": { "refresh": {
"title": "레시피 목록 새로고침" "title": "레시피 목록 새로고침",
"quick": "변경 사항 동기화",
"quickTooltip": "변경 사항 동기화 - 캐시를 재구성하지 않고 빠른 새로고침",
"full": "캐시 재구성",
"fullTooltip": "캐시 재구성 - 모든 레시피 파일을 완전히 다시 스캔"
}, },
"filteredByLora": "LoRA로 필터링됨", "filteredByLora": "LoRA로 필터링됨",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "레시피 복구 실패: {message}", "failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락" "missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
},
"empty": {
"noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "WeChat QR 코드 표시", "showWechatQR": "WeChat QR 코드 표시",
"hideWechatQR": "WeChat QR 코드 숨기기" "hideWechatQR": "WeChat QR 코드 숨기기"
}, },
"footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️" "footer": "LoRA Manager를 사용해주셔서 감사합니다! ❤️",
"supporters": {
"title": "후원자 분들께 감사드립니다",
"subtitle": "이 프로젝트를 가능하게 해준 {count}명의 후원자분들께 감사드립니다",
"specialThanks": "특별 감사",
"allSupporters": "모든 후원자",
"totalCount": "총 {count}명의 후원자"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "{modelType} 로딩 실패: {message}", "loadFailed": "{modelType} 로딩 실패: {message}",
"refreshComplete": "새로고침 완료", "refreshComplete": "새로고침 완료",
"refreshFailed": "레시피 새로고침 실패: {message}", "refreshFailed": "레시피 새로고침 실패: {message}",
"syncComplete": "동기화 완료",
"syncFailed": "레시피 동기화 실패: {message}",
"updateFailed": "레시피 업데이트 실패: {error}", "updateFailed": "레시피 업데이트 실패: {error}",
"updateError": "레시피 업데이트 오류: {message}", "updateError": "레시피 업데이트 오류: {message}",
"nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다", "nameSaved": "레시피 \"{name}\"이 성공적으로 저장되었습니다",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "레시피 저장 실패: {error}", "recipeSaveFailed": "레시피 저장 실패: {error}",
"importFailed": "가져오기 실패: {message}", "importFailed": "가져오기 실패: {message}",
"folderTreeFailed": "폴더 트리 로딩 실패", "folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류" "folderTreeError": "폴더 트리 로딩 오류",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "선택된 모델이 없습니다", "noModelsSelected": "선택된 모델이 없습니다",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"actions": { "actions": {
"save": "Сохранить", "save": "Сохранить",
"cancel": "Отмена", "cancel": "Отмена",
"confirm": "Подтвердить",
"delete": "Удалить", "delete": "Удалить",
"move": "Переместить", "move": "Переместить",
"refresh": "Обновить", "refresh": "Обновить",
@@ -11,7 +14,8 @@
"backToTop": "Наверх", "backToTop": "Наверх",
"settings": "Настройки", "settings": "Настройки",
"help": "Справка", "help": "Справка",
"add": "Добавить" "add": "Добавить",
"close": "Закрыть"
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "Имя пресета...", "presetNamePlaceholder": "Имя пресета...",
"baseModel": "Базовая модель", "baseModel": "Базовая модель",
"modelTags": "Теги (Топ 20)", "modelTags": "Теги (Топ 20)",
"modelTypes": "Model Types", "modelTypes": "Типы моделей",
"license": "Лицензия", "license": "Лицензия",
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
"noDefault": "Не задано" "noDefault": "Не задано"
}, },
"extraFolderPaths": {
"title": "Дополнительные пути к папкам",
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.",
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.",
"modelTypes": {
"lora": "Пути LoRA",
"checkpoint": "Пути Checkpoint",
"unet": "Пути моделей диффузии",
"embedding": "Пути Embedding"
},
"pathPlaceholder": "/путь/к/дополнительным/моделям",
"saveSuccess": "Дополнительные пути к папкам обновлены.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": {
"duplicatePath": "Этот путь уже настроен"
}
},
"priorityTags": { "priorityTags": {
"title": "Приоритетные теги", "title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
@@ -485,23 +506,6 @@
"proxyPassword": "Пароль (необязательно)", "proxyPassword": "Пароль (необязательно)",
"proxyPasswordPlaceholder": "пароль", "proxyPasswordPlaceholder": "пароль",
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)" "proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
},
"extraFolderPaths": {
"title": "Дополнительные пути к папкам",
"help": "Добавьте дополнительные папки моделей за пределами стандартных путей ComfyUI. Эти пути хранятся отдельно и сканируются вместе с папками по умолчанию.",
"description": "Настройте дополнительные папки для сканирования моделей. Эти пути специфичны для LoRA Manager и будут объединены с путями по умолчанию ComfyUI.",
"modelTypes": {
"lora": "Пути LoRA",
"checkpoint": "Пути Checkpoint",
"unet": "Пути моделей диффузии",
"embedding": "Пути Embedding"
},
"pathPlaceholder": "/путь/к/дополнительным/моделям",
"saveSuccess": "Дополнительные пути к папкам обновлены.",
"saveError": "Не удалось обновить дополнительные пути к папкам: {message}",
"validation": {
"duplicatePath": "Этот путь уже настроен"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "Меньше всего" "lorasCountAsc": "Меньше всего"
}, },
"refresh": { "refresh": {
"title": "Обновить список рецептов" "title": "Обновить список рецептов",
"quick": "Синхронизировать изменения",
"quickTooltip": "Синхронизировать изменения - быстрое обновление без перестроения кэша",
"full": "Перестроить кэш",
"fullTooltip": "Перестроить кэш - полное повторное сканирование всех файлов рецептов"
}, },
"filteredByLora": "Фильтр по LoRA", "filteredByLora": "Фильтр по LoRA",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "Не удалось восстановить рецепт: {message}", "failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта" "missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена"
},
"empty": {
"noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "Показать QR-код WeChat", "showWechatQR": "Показать QR-код WeChat",
"hideWechatQR": "Скрыть QR-код WeChat" "hideWechatQR": "Скрыть QR-код WeChat"
}, },
"footer": "Спасибо за использование LoRA Manager! ❤️" "footer": "Спасибо за использование LoRA Manager! ❤️",
"supporters": {
"title": "Спасибо всем сторонникам",
"subtitle": "Спасибо {count} сторонникам, которые сделали этот проект возможным",
"specialThanks": "Особая благодарность",
"allSupporters": "Все сторонники",
"totalCount": "Всего {count} сторонников"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "Не удалось загрузить {modelType}s: {message}", "loadFailed": "Не удалось загрузить {modelType}s: {message}",
"refreshComplete": "Обновление завершено", "refreshComplete": "Обновление завершено",
"refreshFailed": "Не удалось обновить рецепты: {message}", "refreshFailed": "Не удалось обновить рецепты: {message}",
"syncComplete": "Синхронизация завершена",
"syncFailed": "Не удалось синхронизировать рецепты: {message}",
"updateFailed": "Не удалось обновить рецепт: {error}", "updateFailed": "Не удалось обновить рецепт: {error}",
"updateError": "Ошибка обновления рецепта: {message}", "updateError": "Ошибка обновления рецепта: {message}",
"nameSaved": "Рецепт \"{name}\" успешно сохранен", "nameSaved": "Рецепт \"{name}\" успешно сохранен",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}", "recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
"importFailed": "Импорт не удался: {message}", "importFailed": "Импорт не удался: {message}",
"folderTreeFailed": "Не удалось загрузить дерево папок", "folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок" "folderTreeError": "Ошибка загрузки дерева папок",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "Модели не выбраны", "noModelsSelected": "Модели не выбраны",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "取消",
"confirm": "确认",
"actions": { "actions": {
"save": "保存", "save": "保存",
"cancel": "取消", "cancel": "取消",
"confirm": "确认",
"delete": "删除", "delete": "删除",
"move": "移动", "move": "移动",
"refresh": "刷新", "refresh": "刷新",
@@ -11,7 +14,8 @@
"backToTop": "返回顶部", "backToTop": "返回顶部",
"settings": "设置", "settings": "设置",
"help": "帮助", "help": "帮助",
"add": "添加" "add": "添加",
"close": "关闭"
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
@@ -159,11 +163,11 @@
"error": "清理示例图片文件夹失败:{message}" "error": "清理示例图片文件夹失败:{message}"
}, },
"fetchMissingLicenses": { "fetchMissingLicenses": {
"label": "Refresh license metadata", "label": "刷新许可证元数据",
"loading": "Refreshing license metadata for {typePlural}...", "loading": "正在刷新 {typePlural} 的许可证元数据...",
"success": "Updated license metadata for {count} {typePlural}", "success": "已更新 {count} {typePlural} 的许可证元数据",
"none": "All {typePlural} already have license metadata", "none": "所有 {typePlural} 都已具备许可证元数据",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "刷新 {typePlural} 的许可证元数据失败:{message}"
}, },
"repairRecipes": { "repairRecipes": {
"label": "修复配方数据", "label": "修复配方数据",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "预设名称...", "presetNamePlaceholder": "预设名称...",
"baseModel": "基础模型", "baseModel": "基础模型",
"modelTags": "标签前20", "modelTags": "标签前20",
"modelTypes": "Model Types", "modelTypes": "模型类型",
"license": "许可证", "license": "许可证",
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
"noDefault": "无默认" "noDefault": "无默认"
}, },
"extraFolderPaths": {
"title": "额外文件夹路径",
"help": "在 ComfyUI 的标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。",
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。",
"modelTypes": {
"lora": "LoRA 路径",
"checkpoint": "Checkpoint 路径",
"unet": "Diffusion 模型路径",
"embedding": "Embedding 路径"
},
"pathPlaceholder": "/额外/模型/路径",
"saveSuccess": "额外文件夹路径已更新。",
"saveError": "更新额外文件夹路径失败:{message}",
"validation": {
"duplicatePath": "此路径已配置"
}
},
"priorityTags": { "priorityTags": {
"title": "优先标签", "title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
@@ -485,23 +506,6 @@
"proxyPassword": "密码 (可选)", "proxyPassword": "密码 (可选)",
"proxyPasswordPlaceholder": "密码", "proxyPasswordPlaceholder": "密码",
"proxyPasswordHelp": "代理认证的密码 (如果需要)" "proxyPasswordHelp": "代理认证的密码 (如果需要)"
},
"extraFolderPaths": {
"title": "额外文件夹路径",
"help": "在 ComfyUI 的标准路径之外添加额外的模型文件夹。这些路径单独存储,并与默认文件夹一起扫描。",
"description": "配置额外的文件夹以扫描模型。这些路径是 LoRA Manager 特有的,将与 ComfyUI 的默认路径合并。",
"modelTypes": {
"lora": "LoRA 路径",
"checkpoint": "Checkpoint 路径",
"unet": "Diffusion 模型路径",
"embedding": "Embedding 路径"
},
"pathPlaceholder": "/额外/模型/路径",
"saveSuccess": "额外文件夹路径已更新。",
"saveError": "更新额外文件夹路径失败:{message}",
"validation": {
"duplicatePath": "此路径已配置"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "最少" "lorasCountAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "刷新配方列表" "title": "刷新配方列表",
"quick": "同步变更",
"quickTooltip": "同步变更 - 快速刷新而不重建缓存",
"full": "重建缓存",
"fullTooltip": "重建缓存 - 重新扫描所有配方文件"
}, },
"filteredByLora": "按 LoRA 筛选", "filteredByLora": "按 LoRA 筛选",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "修复配方失败:{message}", "failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID" "missingId": "无法修复配方:缺少配方 ID"
} }
},
"batchImport": {
"title": "批量导入配方",
"action": "批量导入",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"urlsLabel": "图片 URL 或本地路径",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "/图片/文件夹/路径",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "标签(可选,应用于所有配方)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "跳过无元数据的图片",
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
"start": "[TODO: Translate] Start Import",
"startImport": "开始导入",
"importing": "正在导入配方...",
"progress": "进度",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "当前",
"preparing": "准备中...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "取消",
"cancelled": "批量导入已取消",
"completed": "导入完成",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "成功导入 {count} 个配方",
"successCount": "成功",
"failedCount": "失败",
"skippedCount": "跳过",
"totalProcessed": "总计处理",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "请至少输入一个 URL 或路径",
"enterDirectory": "请输入目录路径",
"startFailed": "启动导入失败:{message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。", "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认Escape 取消",
"emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作"
},
"empty": {
"noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "显示微信二维码", "showWechatQR": "显示微信二维码",
"hideWechatQR": "隐藏微信二维码" "hideWechatQR": "隐藏微信二维码"
}, },
"footer": "感谢使用 LoRA 管理器!❤️" "footer": "感谢使用 LoRA 管理器!❤️",
"supporters": {
"title": "感谢所有支持者",
"subtitle": "感谢 {count} 位支持者让这个项目成为可能",
"specialThanks": "特别感谢",
"allSupporters": "所有支持者",
"totalCount": "共 {count} 位支持者"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "加载 {modelType} 失败:{message}", "loadFailed": "加载 {modelType} 失败:{message}",
"refreshComplete": "刷新完成", "refreshComplete": "刷新完成",
"refreshFailed": "刷新配方失败:{message}", "refreshFailed": "刷新配方失败:{message}",
"syncComplete": "同步完成",
"syncFailed": "同步配方失败:{message}",
"updateFailed": "更新配方失败:{error}", "updateFailed": "更新配方失败:{error}",
"updateError": "更新配方出错:{message}", "updateError": "更新配方出错:{message}",
"nameSaved": "配方“{name}”保存成功", "nameSaved": "配方“{name}”保存成功",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "保存配方失败:{error}", "recipeSaveFailed": "保存配方失败:{error}",
"importFailed": "导入失败:{message}", "importFailed": "导入失败:{message}",
"folderTreeFailed": "加载文件夹树失败", "folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错" "folderTreeError": "加载文件夹树出错",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "未选中模型", "noModelsSelected": "未选中模型",

View File

@@ -1,8 +1,11 @@
{ {
"common": { "common": {
"cancel": "取消",
"confirm": "確認",
"actions": { "actions": {
"save": "儲存", "save": "儲存",
"cancel": "取消", "cancel": "取消",
"confirm": "確認",
"delete": "刪除", "delete": "刪除",
"move": "移動", "move": "移動",
"refresh": "重新整理", "refresh": "重新整理",
@@ -11,7 +14,8 @@
"backToTop": "回到頂部", "backToTop": "回到頂部",
"settings": "設定", "settings": "設定",
"help": "說明", "help": "說明",
"add": "新增" "add": "新增",
"close": "關閉"
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
@@ -219,7 +223,7 @@
"presetNamePlaceholder": "預設名稱...", "presetNamePlaceholder": "預設名稱...",
"baseModel": "基礎模型", "baseModel": "基礎模型",
"modelTags": "標籤(前 20", "modelTags": "標籤(前 20",
"modelTypes": "Model Types", "modelTypes": "模型類型",
"license": "授權", "license": "授權",
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
@@ -361,6 +365,23 @@
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
"noDefault": "未設定預設" "noDefault": "未設定預設"
}, },
"extraFolderPaths": {
"title": "額外資料夾路徑",
"help": "在 ComfyUI 的標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。",
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。",
"modelTypes": {
"lora": "LoRA 路徑",
"checkpoint": "Checkpoint 路徑",
"unet": "Diffusion 模型路徑",
"embedding": "Embedding 路徑"
},
"pathPlaceholder": "/額外/模型/路徑",
"saveSuccess": "額外資料夾路徑已更新。",
"saveError": "更新額外資料夾路徑失敗:{message}",
"validation": {
"duplicatePath": "此路徑已設定"
}
},
"priorityTags": { "priorityTags": {
"title": "優先標籤", "title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
@@ -485,23 +506,6 @@
"proxyPassword": "密碼(選填)", "proxyPassword": "密碼(選填)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)" "proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
},
"extraFolderPaths": {
"title": "額外資料夾路徑",
"help": "在 ComfyUI 的標準路徑之外新增額外的模型資料夾。這些路徑單獨儲存,並與預設資料夾一起掃描。",
"description": "設定額外的資料夾以掃描模型。這些路徑是 LoRA Manager 特有的,將與 ComfyUI 的預設路徑合併。",
"modelTypes": {
"lora": "LoRA 路徑",
"checkpoint": "Checkpoint 路徑",
"unet": "Diffusion 模型路徑",
"embedding": "Embedding 路徑"
},
"pathPlaceholder": "/額外/模型/路徑",
"saveSuccess": "額外資料夾路徑已更新。",
"saveError": "更新額外資料夾路徑失敗:{message}",
"validation": {
"duplicatePath": "此路徑已設定"
}
} }
}, },
"loras": { "loras": {
@@ -682,7 +686,11 @@
"lorasCountAsc": "最少" "lorasCountAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "重新整理配方列表" "title": "重新整理配方列表",
"quick": "同步變更",
"quickTooltip": "同步變更 - 快速重新整理而不重建快取",
"full": "重建快取",
"fullTooltip": "重建快取 - 重新掃描所有配方檔案"
}, },
"filteredByLora": "已依 LoRA 篩選", "filteredByLora": "已依 LoRA 篩選",
"favorites": { "favorites": {
@@ -722,6 +730,64 @@
"failed": "修復配方失敗:{message}", "failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID" "missingId": "無法修復配方:缺少配方 ID"
} }
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
}
} }
}, },
"checkpoints": { "checkpoints": {
@@ -750,7 +816,17 @@
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。", "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "Move is not supported for this item." "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認Escape 取消",
"emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作"
},
"empty": {
"noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾"
} }
}, },
"statistics": { "statistics": {
@@ -1342,7 +1418,14 @@
"showWechatQR": "顯示微信二維碼", "showWechatQR": "顯示微信二維碼",
"hideWechatQR": "隱藏微信二維碼" "hideWechatQR": "隱藏微信二維碼"
}, },
"footer": "感謝您使用 LoRA 管理器!❤️" "footer": "感謝您使用 LoRA 管理器!❤️",
"supporters": {
"title": "感謝所有支持者",
"subtitle": "感謝 {count} 位支持者讓這個專案成為可能",
"specialThanks": "特別感謝",
"allSupporters": "所有支持者",
"totalCount": "共 {count} 位支持者"
}
}, },
"toast": { "toast": {
"general": { "general": {
@@ -1376,6 +1459,8 @@
"loadFailed": "載入 {modelType} 失敗:{message}", "loadFailed": "載入 {modelType} 失敗:{message}",
"refreshComplete": "刷新完成", "refreshComplete": "刷新完成",
"refreshFailed": "刷新配方失敗:{message}", "refreshFailed": "刷新配方失敗:{message}",
"syncComplete": "同步完成",
"syncFailed": "同步配方失敗:{message}",
"updateFailed": "更新配方失敗:{error}", "updateFailed": "更新配方失敗:{error}",
"updateError": "更新配方錯誤:{message}", "updateError": "更新配方錯誤:{message}",
"nameSaved": "配方「{name}」已成功儲存", "nameSaved": "配方「{name}」已成功儲存",
@@ -1412,7 +1497,14 @@
"recipeSaveFailed": "儲存配方失敗:{error}", "recipeSaveFailed": "儲存配方失敗:{error}",
"importFailed": "匯入失敗:{message}", "importFailed": "匯入失敗:{message}",
"folderTreeFailed": "載入資料夾樹狀結構失敗", "folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤" "folderTreeError": "載入資料夾樹狀結構錯誤",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
}, },
"models": { "models": {
"noModelsSelected": "未選擇模型", "noModelsSelected": "未選擇模型",

View File

@@ -2,7 +2,7 @@ import os
import platform import platform
import threading import threading
from pathlib import Path from pathlib import Path
import folder_paths # type: ignore import folder_paths # type: ignore
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
import logging import logging
import json import json
@@ -10,16 +10,23 @@ import urllib.parse
import time import time
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template from .utils.settings_paths import (
ensure_settings_file,
get_settings_dir,
load_settings_template,
)
# Use an environment variable to control standalone mode # Use an environment variable to control standalone mode
standalone_mode = 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"
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _normalize_folder_paths_for_comparison( def _normalize_folder_paths_for_comparison(
folder_paths: Mapping[str, Iterable[str]] folder_paths: Mapping[str, Iterable[str]],
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
"""Normalize folder paths for comparison across libraries.""" """Normalize folder paths for comparison across libraries."""
@@ -49,7 +56,7 @@ def _normalize_folder_paths_for_comparison(
def _normalize_library_folder_paths( def _normalize_library_folder_paths(
library_payload: Mapping[str, Any] library_payload: Mapping[str, Any],
) -> Dict[str, Set[str]]: ) -> Dict[str, Set[str]]:
"""Return normalized folder paths extracted from a library payload.""" """Return normalized folder paths extracted from a library payload."""
@@ -76,9 +83,15 @@ class Config:
"""Global configuration for LoRA Manager""" """Global configuration for LoRA Manager"""
def __init__(self): def __init__(self):
self.templates_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'templates') self.templates_path = os.path.join(
self.static_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static') os.path.dirname(os.path.dirname(__file__)), "templates"
self.i18n_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'locales') )
self.static_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "static"
)
self.i18n_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "locales"
)
# Path mapping dictionary, target to link mapping # Path mapping dictionary, target to link mapping
self._path_mappings: Dict[str, str] = {} self._path_mappings: Dict[str, str] = {}
# Normalized preview root directories used to validate preview access # Normalized preview root directories used to validate preview access
@@ -152,17 +165,21 @@ class Config:
default_library = libraries.get("default", {}) default_library = libraries.get("default", {})
target_folder_paths = { target_folder_paths = {
'loras': list(self.loras_roots), "loras": list(self.loras_roots),
'checkpoints': list(self.checkpoints_roots or []), "checkpoints": list(self.checkpoints_roots or []),
'unet': list(self.unet_roots or []), "unet": list(self.unet_roots or []),
'embeddings': list(self.embeddings_roots or []), "embeddings": list(self.embeddings_roots or []),
} }
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths) normalized_target_paths = _normalize_folder_paths_for_comparison(
target_folder_paths
)
normalized_default_paths: Optional[Dict[str, Set[str]]] = None normalized_default_paths: Optional[Dict[str, Set[str]]] = None
if isinstance(default_library, Mapping): if isinstance(default_library, Mapping):
normalized_default_paths = _normalize_library_folder_paths(default_library) normalized_default_paths = _normalize_library_folder_paths(
default_library
)
if ( if (
not comfy_library not comfy_library
@@ -185,13 +202,19 @@ class Config:
default_lora_root = self.loras_roots[0] default_lora_root = self.loras_roots[0]
default_checkpoint_root = comfy_library.get("default_checkpoint_root", "") default_checkpoint_root = comfy_library.get("default_checkpoint_root", "")
if (not default_checkpoint_root and self.checkpoints_roots and if (
len(self.checkpoints_roots) == 1): not default_checkpoint_root
and self.checkpoints_roots
and len(self.checkpoints_roots) == 1
):
default_checkpoint_root = self.checkpoints_roots[0] default_checkpoint_root = self.checkpoints_roots[0]
default_embedding_root = comfy_library.get("default_embedding_root", "") default_embedding_root = comfy_library.get("default_embedding_root", "")
if (not default_embedding_root and self.embeddings_roots and if (
len(self.embeddings_roots) == 1): not default_embedding_root
and self.embeddings_roots
and len(self.embeddings_roots) == 1
):
default_embedding_root = self.embeddings_roots[0] default_embedding_root = self.embeddings_roots[0]
metadata = dict(comfy_library.get("metadata", {})) metadata = dict(comfy_library.get("metadata", {}))
@@ -216,11 +239,12 @@ class Config:
try: try:
if os.path.islink(path): if os.path.islink(path):
return True return True
if platform.system() == 'Windows': if platform.system() == "Windows":
try: try:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path)) # type: ignore[attr-defined]
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception as e: except Exception as e:
logger.error(f"Error checking Windows reparse point: {e}") logger.error(f"Error checking Windows reparse point: {e}")
@@ -233,18 +257,19 @@ class Config:
"""Check if a directory entry is a symlink, including Windows junctions.""" """Check if a directory entry is a symlink, including Windows junctions."""
if entry.is_symlink(): if entry.is_symlink():
return True return True
if platform.system() == 'Windows': if platform.system() == "Windows":
try: try:
import ctypes import ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400 FILE_ATTRIBUTE_REPARSE_POINT = 0x400
attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) attrs = ctypes.windll.kernel32.GetFileAttributesW(entry.path) # type: ignore[attr-defined]
return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT) return attrs != -1 and (attrs & FILE_ATTRIBUTE_REPARSE_POINT)
except Exception: except Exception:
pass pass
return False return False
def _normalize_path(self, path: str) -> str: def _normalize_path(self, path: str) -> str:
return os.path.normpath(path).replace(os.sep, '/') return os.path.normpath(path).replace(os.sep, "/")
def _get_symlink_cache_path(self) -> Path: def _get_symlink_cache_path(self) -> Path:
canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True) canonical_path = get_cache_file_path(CacheType.SYMLINK, create_dir=True)
@@ -278,19 +303,18 @@ class Config:
if self._entry_is_symlink(entry): if self._entry_is_symlink(entry):
try: try:
target = os.path.realpath(entry.path) target = os.path.realpath(entry.path)
direct_symlinks.append([ direct_symlinks.append(
self._normalize_path(entry.path), [
self._normalize_path(target) self._normalize_path(entry.path),
]) self._normalize_path(target),
]
)
except OSError: except OSError:
pass pass
except (OSError, PermissionError): except (OSError, PermissionError):
pass pass
return { return {"roots": unique_roots, "direct_symlinks": sorted(direct_symlinks)}
"roots": unique_roots,
"direct_symlinks": sorted(direct_symlinks)
}
def _initialize_symlink_mappings(self) -> None: def _initialize_symlink_mappings(self) -> None:
start = time.perf_counter() start = time.perf_counter()
@@ -307,10 +331,14 @@ class Config:
cached_fingerprint = self._cached_fingerprint cached_fingerprint = self._cached_fingerprint
# Check 1: First-level symlinks unchanged (catches new symlinks at root) # Check 1: First-level symlinks unchanged (catches new symlinks at root)
fingerprint_valid = cached_fingerprint and current_fingerprint == cached_fingerprint fingerprint_valid = (
cached_fingerprint and current_fingerprint == cached_fingerprint
)
# Check 2: All cached mappings still valid (catches changes at any depth) # Check 2: All cached mappings still valid (catches changes at any depth)
mappings_valid = self._validate_cached_mappings() if fingerprint_valid else False mappings_valid = (
self._validate_cached_mappings() if fingerprint_valid else False
)
if fingerprint_valid and mappings_valid: if fingerprint_valid and mappings_valid:
return return
@@ -370,7 +398,9 @@ class Config:
for target, link in cached_mappings.items(): for target, link in cached_mappings.items():
if not isinstance(target, str) or not isinstance(link, str): if not isinstance(target, str) or not isinstance(link, str):
continue continue
normalized_mappings[self._normalize_path(target)] = self._normalize_path(link) normalized_mappings[self._normalize_path(target)] = self._normalize_path(
link
)
self._path_mappings = normalized_mappings self._path_mappings = normalized_mappings
@@ -391,7 +421,9 @@ class Config:
parent_dir = loaded_path.parent parent_dir = loaded_path.parent
if parent_dir.name == "cache" and not any(parent_dir.iterdir()): if parent_dir.name == "cache" and not any(parent_dir.iterdir()):
parent_dir.rmdir() parent_dir.rmdir()
logger.info("Removed empty legacy cache directory: %s", parent_dir) logger.info(
"Removed empty legacy cache directory: %s", parent_dir
)
except Exception: except Exception:
pass pass
@@ -402,7 +434,9 @@ class Config:
exc, exc,
) )
else: else:
logger.info("Symlink cache loaded with %d mappings", len(self._path_mappings)) logger.info(
"Symlink cache loaded with %d mappings", len(self._path_mappings)
)
return True return True
@@ -414,7 +448,7 @@ class Config:
""" """
for target, link in self._path_mappings.items(): for target, link in self._path_mappings.items():
# Convert normalized paths back to OS paths # Convert normalized paths back to OS paths
link_path = link.replace('/', os.sep) link_path = link.replace("/", os.sep)
# Check if symlink still exists # Check if symlink still exists
if not self._is_link(link_path): if not self._is_link(link_path):
@@ -427,7 +461,9 @@ class Config:
if actual_target != target: if actual_target != target:
logger.debug( logger.debug(
"Symlink target changed: %s -> %s (cached: %s)", "Symlink target changed: %s -> %s (cached: %s)",
link_path, actual_target, target link_path,
actual_target,
target,
) )
return False return False
except OSError: except OSError:
@@ -446,7 +482,11 @@ class Config:
try: try:
with cache_path.open("w", encoding="utf-8") as handle: with cache_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2) json.dump(payload, handle, ensure_ascii=False, indent=2)
logger.debug("Symlink cache saved to %s with %d mappings", cache_path, len(self._path_mappings)) logger.debug(
"Symlink cache saved to %s with %d mappings",
cache_path,
len(self._path_mappings),
)
except Exception as exc: except Exception as exc:
logger.info("Failed to write symlink cache %s: %s", cache_path, exc) logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
@@ -494,13 +534,13 @@ class Config:
self.add_path_mapping(entry.path, target_path) self.add_path_mapping(entry.path, target_path)
except Exception as inner_exc: except Exception as inner_exc:
logger.debug( logger.debug(
"Error processing directory entry %s: %s", entry.path, inner_exc "Error processing directory entry %s: %s",
entry.path,
inner_exc,
) )
except Exception as e: except Exception as e:
logger.error(f"Error scanning links in {root}: {e}") logger.error(f"Error scanning links in {root}: {e}")
def add_path_mapping(self, link_path: str, target_path: str): def add_path_mapping(self, link_path: str, target_path: str):
"""Add a symbolic link path mapping """Add a symbolic link path mapping
target_path: actual target path target_path: actual target path
@@ -594,26 +634,31 @@ class Config:
preview_roots.update(self._expand_preview_root(target)) preview_roots.update(self._expand_preview_root(target))
preview_roots.update(self._expand_preview_root(link)) preview_roots.update(self._expand_preview_root(link))
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()} self._preview_root_paths = {
path for path in preview_roots if path.is_absolute()
}
logger.debug( logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings", "Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings",
len(self._preview_root_paths), len(self._preview_root_paths),
len(self.loras_roots or []), len(self.extra_loras_roots or []), len(self.loras_roots or []),
len(self.base_models_roots or []), len(self.extra_checkpoints_roots or []), len(self.extra_loras_roots or []),
len(self.embeddings_roots or []), len(self.extra_embeddings_roots or []), len(self.base_models_roots or []),
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []),
len(self._path_mappings), len(self._path_mappings),
) )
def map_path_to_link(self, path: str) -> str: def map_path_to_link(self, path: str) -> str:
"""Map a target path back to its symbolic link path""" """Map a target path back to its symbolic link path"""
normalized_path = os.path.normpath(path).replace(os.sep, '/') normalized_path = os.path.normpath(path).replace(os.sep, "/")
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path in self._path_mappings.items(): for target_path, link_path in self._path_mappings.items():
# Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc) # Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc)
if normalized_path == target_path: if normalized_path == target_path:
return link_path return link_path
if normalized_path.startswith(target_path + '/'): if normalized_path.startswith(target_path + "/"):
# If the path starts with the target path, replace with link path # If the path starts with the target path, replace with link path
mapped_path = normalized_path.replace(target_path, link_path, 1) mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path return mapped_path
@@ -621,14 +666,14 @@ class Config:
def map_link_to_path(self, link_path: str) -> str: def map_link_to_path(self, link_path: str) -> str:
"""Map a symbolic link path back to the actual path""" """Map a symbolic link path back to the actual path"""
normalized_link = os.path.normpath(link_path).replace(os.sep, '/') normalized_link = os.path.normpath(link_path).replace(os.sep, "/")
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path_mapped in self._path_mappings.items(): for target_path, link_path_mapped in self._path_mappings.items():
# Match whole path components # Match whole path components
if normalized_link == link_path_mapped: if normalized_link == link_path_mapped:
return target_path return target_path
if normalized_link.startswith(link_path_mapped + '/'): if normalized_link.startswith(link_path_mapped + "/"):
# If the path starts with the link path, replace with actual path # If the path starts with the link path, replace with actual path
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1) mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
return mapped_path return mapped_path
@@ -641,8 +686,8 @@ class Config:
continue continue
if not os.path.exists(path): if not os.path.exists(path):
continue continue
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, "/")
normalized = os.path.normpath(path).replace(os.sep, '/') normalized = os.path.normpath(path).replace(os.sep, "/")
if real_path not in dedup: if real_path not in dedup:
dedup[real_path] = normalized dedup[real_path] = normalized
return dedup return dedup
@@ -652,7 +697,9 @@ class Config:
unique_paths = sorted(path_map.values(), key=lambda p: p.lower()) unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(original_path)).replace(
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
@@ -674,7 +721,7 @@ class Config:
"Please fix your ComfyUI path configuration to separate these folders. " "Please fix your ComfyUI path configuration to separate these folders. "
"Falling back to 'checkpoints' for backward compatibility. " "Falling back to 'checkpoints' for backward compatibility. "
"Overlapping real paths: %s", "Overlapping real paths: %s",
[checkpoint_map.get(rp, rp) for rp in overlapping_real_paths] [checkpoint_map.get(rp, rp) for rp in overlapping_real_paths],
) )
# Remove overlapping paths from unet_map to prioritize checkpoints # Remove overlapping paths from unet_map to prioritize checkpoints
for rp in overlapping_real_paths: for rp in overlapping_real_paths:
@@ -694,7 +741,9 @@ class Config:
self.unet_roots = [p for p in unique_paths if p in unet_values] self.unet_roots = [p for p in unique_paths if p in unet_values]
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(original_path)).replace(
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
@@ -705,7 +754,9 @@ class Config:
unique_paths = sorted(path_map.values(), key=lambda p: p.lower()) unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
for original_path in unique_paths: for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/') real_path = os.path.normpath(os.path.realpath(original_path)).replace(
os.sep, "/"
)
if real_path != original_path: if real_path != original_path:
self.add_path_mapping(original_path, real_path) self.add_path_mapping(original_path, real_path)
@@ -719,28 +770,66 @@ class Config:
self._path_mappings.clear() self._path_mappings.clear()
self._preview_root_paths = set() self._preview_root_paths = set()
lora_paths = folder_paths.get('loras', []) or [] lora_paths = folder_paths.get("loras", []) or []
checkpoint_paths = folder_paths.get('checkpoints', []) or [] checkpoint_paths = folder_paths.get("checkpoints", []) or []
unet_paths = folder_paths.get('unet', []) or [] unet_paths = folder_paths.get("unet", []) or []
embedding_paths = folder_paths.get('embeddings', []) or [] embedding_paths = folder_paths.get("embeddings", []) or []
self.loras_roots = self._prepare_lora_paths(lora_paths) self.loras_roots = self._prepare_lora_paths(lora_paths)
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths) self.base_models_roots = self._prepare_checkpoint_paths(
checkpoint_paths, unet_paths
)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths) self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
# Process extra paths (only for LoRA Manager, not shared with ComfyUI) # Process extra paths (only for LoRA Manager, not shared with ComfyUI)
extra_paths = extra_folder_paths or {} extra_paths = extra_folder_paths or {}
extra_lora_paths = extra_paths.get('loras', []) or [] extra_lora_paths = extra_paths.get("loras", []) or []
extra_checkpoint_paths = extra_paths.get('checkpoints', []) or [] extra_checkpoint_paths = extra_paths.get("checkpoints", []) or []
extra_unet_paths = extra_paths.get('unet', []) or [] extra_unet_paths = extra_paths.get("unet", []) or []
extra_embedding_paths = extra_paths.get('embeddings', []) or [] extra_embedding_paths = extra_paths.get("embeddings", []) or []
self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths) self.extra_loras_roots = self._prepare_lora_paths(extra_lora_paths)
self.extra_checkpoints_roots = self._prepare_checkpoint_paths(extra_checkpoint_paths, extra_unet_paths) # Save main paths before processing extra paths ( _prepare_checkpoint_paths overwrites them)
self.extra_embeddings_roots = self._prepare_embedding_paths(extra_embedding_paths) saved_checkpoints_roots = self.checkpoints_roots
# extra_unet_roots is set by _prepare_checkpoint_paths (access unet_roots before it's reset) saved_unet_roots = self.unet_roots
unet_roots_value: List[str] = getattr(self, 'unet_roots', None) or [] self.extra_checkpoints_roots = self._prepare_checkpoint_paths(
self.extra_unet_roots = unet_roots_value 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_embeddings_roots = self._prepare_embedding_paths(
extra_embedding_paths
)
# Log extra folder paths
if self.extra_loras_roots:
logger.info(
"Found extra LoRA roots:"
+ "\n - "
+ "\n - ".join(self.extra_loras_roots)
)
if self.extra_checkpoints_roots:
logger.info(
"Found extra checkpoint roots:"
+ "\n - "
+ "\n - ".join(self.extra_checkpoints_roots)
)
if self.extra_unet_roots:
logger.info(
"Found extra diffusion model roots:"
+ "\n - "
+ "\n - ".join(self.extra_unet_roots)
)
if self.extra_embeddings_roots:
logger.info(
"Found extra embedding roots:"
+ "\n - "
+ "\n - ".join(self.extra_embeddings_roots)
)
self._initialize_symlink_mappings() self._initialize_symlink_mappings()
@@ -749,7 +838,10 @@ class Config:
try: try:
raw_paths = folder_paths.get_folder_paths("loras") raw_paths = folder_paths.get_folder_paths("loras")
unique_paths = self._prepare_lora_paths(raw_paths) unique_paths = self._prepare_lora_paths(raw_paths)
logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")) logger.info(
"Found LoRA roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning("No valid loras folders found in ComfyUI configuration") logger.warning("No valid loras folders found in ComfyUI configuration")
@@ -765,12 +857,19 @@ class Config:
try: try:
raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints") raw_checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
raw_unet_paths = folder_paths.get_folder_paths("unet") raw_unet_paths = folder_paths.get_folder_paths("unet")
unique_paths = self._prepare_checkpoint_paths(raw_checkpoint_paths, raw_unet_paths) unique_paths = self._prepare_checkpoint_paths(
raw_checkpoint_paths, raw_unet_paths
)
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")) logger.info(
"Found checkpoint roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning("No valid checkpoint folders found in ComfyUI configuration") logger.warning(
"No valid checkpoint folders found in ComfyUI configuration"
)
return [] return []
return unique_paths return unique_paths
@@ -783,10 +882,15 @@ class Config:
try: try:
raw_paths = folder_paths.get_folder_paths("embeddings") raw_paths = folder_paths.get_folder_paths("embeddings")
unique_paths = self._prepare_embedding_paths(raw_paths) unique_paths = self._prepare_embedding_paths(raw_paths)
logger.info("Found embedding roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")) logger.info(
"Found embedding roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths: if not unique_paths:
logger.warning("No valid embeddings folders found in ComfyUI configuration") logger.warning(
"No valid embeddings folders found in ComfyUI configuration"
)
return [] return []
return unique_paths return unique_paths
@@ -798,9 +902,9 @@ class Config:
if not preview_path: if not preview_path:
return "" return ""
normalized = os.path.normpath(preview_path).replace(os.sep, '/') normalized = os.path.normpath(preview_path).replace(os.sep, "/")
encoded_path = urllib.parse.quote(normalized, safe='') encoded_path = urllib.parse.quote(normalized, safe="")
return f'/api/lm/previews?path={encoded_path}' return f"/api/lm/previews?path={encoded_path}"
def is_preview_path_allowed(self, preview_path: str) -> bool: def is_preview_path_allowed(self, preview_path: str) -> bool:
"""Return ``True`` if ``preview_path`` is within an allowed directory. """Return ``True`` if ``preview_path`` is within an allowed directory.
@@ -875,14 +979,18 @@ class Config:
normalized_link = self._normalize_path(str(current)) normalized_link = self._normalize_path(str(current))
self._path_mappings[normalized_target] = normalized_link self._path_mappings[normalized_target] = normalized_link
self._preview_root_paths.update(self._expand_preview_root(normalized_target)) self._preview_root_paths.update(
self._preview_root_paths.update(self._expand_preview_root(normalized_link)) self._expand_preview_root(normalized_target)
)
self._preview_root_paths.update(
self._expand_preview_root(normalized_link)
)
logger.debug( logger.debug(
"Discovered deep symlink: %s -> %s (preview path: %s)", "Discovered deep symlink: %s -> %s (preview path: %s)",
normalized_link, normalized_link,
normalized_target, normalized_target,
preview_path preview_path,
) )
return True return True
@@ -900,8 +1008,16 @@ class Config:
def apply_library_settings(self, library_config: Mapping[str, object]) -> None: def apply_library_settings(self, library_config: Mapping[str, object]) -> None:
"""Update runtime paths to match the provided library configuration.""" """Update runtime paths to match the provided library configuration."""
folder_paths = library_config.get('folder_paths') if isinstance(library_config, Mapping) else {} folder_paths = (
extra_folder_paths = library_config.get('extra_folder_paths') if isinstance(library_config, Mapping) else None library_config.get("folder_paths")
if isinstance(library_config, Mapping)
else {}
)
extra_folder_paths = (
library_config.get("extra_folder_paths")
if isinstance(library_config, Mapping)
else None
)
if not isinstance(folder_paths, Mapping): if not isinstance(folder_paths, Mapping):
folder_paths = {} folder_paths = {}
if not isinstance(extra_folder_paths, Mapping): if not isinstance(extra_folder_paths, Mapping):
@@ -911,9 +1027,12 @@ class Config:
logger.info( logger.info(
"Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)", "Applied library settings with %d lora roots (%d extra), %d checkpoint roots (%d extra), and %d embedding roots (%d extra)",
len(self.loras_roots or []), len(self.extra_loras_roots or []), len(self.loras_roots or []),
len(self.base_models_roots or []), len(self.extra_checkpoints_roots or []), len(self.extra_loras_roots or []),
len(self.embeddings_roots or []), len(self.extra_embeddings_roots or []), len(self.base_models_roots or []),
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []),
) )
def get_library_registry_snapshot(self) -> Dict[str, object]: def get_library_registry_snapshot(self) -> Dict[str, object]:
@@ -933,5 +1052,6 @@ class Config:
logger.debug("Failed to collect library registry snapshot: %s", exc) logger.debug("Failed to collect library registry snapshot: %s", exc)
return {"active_library": "", "libraries": {}} return {"active_library": "", "libraries": {}}
# Global config instance # Global config instance
config = Config() config = Config()

View File

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

View File

@@ -4,7 +4,10 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Check if running in standalone mode # Check if running in standalone mode
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: if not standalone_mode:
from .metadata_hook import MetadataHook from .metadata_hook import MetadataHook
@@ -19,7 +22,7 @@ if not standalone_mode:
logger.info("ComfyUI Metadata Collector initialized") logger.info("ComfyUI Metadata Collector initialized")
def get_metadata(prompt_id=None): def get_metadata(prompt_id=None): # type: ignore[no-redef]
"""Helper function to get metadata from the registry""" """Helper function to get metadata from the registry"""
registry = MetadataRegistry() registry = MetadataRegistry()
return registry.get_metadata(prompt_id) return registry.get_metadata(prompt_id)
@@ -28,6 +31,6 @@ else:
def init(): def init():
logger.info("ComfyUI Metadata Collector disabled in standalone mode") logger.info("ComfyUI Metadata Collector disabled in standalone mode")
def get_metadata(prompt_id=None): def get_metadata(prompt_id=None): # type: ignore[no-redef]
"""Dummy implementation for standalone mode""" """Dummy implementation for standalone mode"""
return {} return {}

View File

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

View File

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

View File

@@ -1,33 +1,35 @@
class AnyType(str): class AnyType(str):
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss""" """A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
def __ne__(self, __value: object) -> bool:
return False
def __ne__(self, __value: object) -> bool:
return False
# Credit to Regis Gaughan, III (rgthree) # Credit to Regis Gaughan, III (rgthree)
class FlexibleOptionalInputType(dict): class FlexibleOptionalInputType(dict):
"""A special class to make flexible nodes that pass data to our python handlers. """A special class to make flexible nodes that pass data to our python handlers.
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
(like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc). (like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
Note, for ComfyUI, all that's needed is the `__contains__` override below, which tells ComfyUI Note, for ComfyUI, all that's needed is the `__contains__` override below, which tells ComfyUI
that our node will handle the input, regardless of what it is. that our node will handle the input, regardless of what it is.
However, with https://github.com/comfyanonymous/ComfyUI/pull/2666 a large change would occur However, with https://github.com/comfyanonymous/ComfyUI/pull/2666 a large change would occur
requiring more details on the input itself. There, we need to return a list/tuple where the first requiring more details on the input itself. There, we need to return a list/tuple where the first
item is the type. This can be a real type, or use the AnyType for additional flexibility. item is the type. This can be a real type, or use the AnyType for additional flexibility.
This should be forwards compatible unless more changes occur in the PR. This should be forwards compatible unless more changes occur in the PR.
""" """
def __init__(self, type):
self.type = type
def __getitem__(self, key): def __init__(self, type):
return (self.type, ) self.type = type
def __contains__(self, key): def __getitem__(self, key):
return True return (self.type,)
def __contains__(self, key):
return True
any_type = AnyType("*") any_type = AnyType("*")
@@ -37,25 +39,27 @@ import os
import logging import logging
import copy import copy
import sys import sys
import folder_paths import folder_paths # type: ignore
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def extract_lora_name(lora_path): def extract_lora_name(lora_path):
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')""" """Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
# Get the basename without extension # Get the basename without extension
basename = os.path.basename(lora_path) basename = os.path.basename(lora_path)
return os.path.splitext(basename)[0] return os.path.splitext(basename)[0]
def get_loras_list(kwargs): def get_loras_list(kwargs):
"""Helper to extract loras list from either old or new kwargs format""" """Helper to extract loras list from either old or new kwargs format"""
if 'loras' not in kwargs: if "loras" not in kwargs:
return [] return []
loras_data = kwargs['loras'] loras_data = kwargs["loras"]
# Handle new format: {'loras': {'__value__': [...]}} # Handle new format: {'loras': {'__value__': [...]}}
if isinstance(loras_data, dict) and '__value__' in loras_data: if isinstance(loras_data, dict) and "__value__" in loras_data:
return loras_data['__value__'] return loras_data["__value__"]
# Handle old format: {'loras': [...]} # Handle old format: {'loras': [...]}
elif isinstance(loras_data, list): elif isinstance(loras_data, list):
return loras_data return loras_data
@@ -64,23 +68,25 @@ def get_loras_list(kwargs):
logger.warning(f"Unexpected loras format: {type(loras_data)}") logger.warning(f"Unexpected loras format: {type(loras_data)}")
return [] return []
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""): def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path""" """Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
import safetensors.torch import safetensors.torch
state_dict = {} state_dict = {}
with safetensors.torch.safe_open(path, framework="pt", device=device) as f: with safetensors.torch.safe_open(path, framework="pt", device=device) as f: # type: ignore[attr-defined]
for k in f.keys(): for k in f.keys():
if filter_prefix and not k.startswith(filter_prefix): if filter_prefix and not k.startswith(filter_prefix):
continue continue
state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k) state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k)
return state_dict return state_dict
def to_diffusers(input_lora): def to_diffusers(input_lora):
"""Simplified version of to_diffusers for Flux LoRA conversion""" """Simplified version of to_diffusers for Flux LoRA conversion"""
import torch import torch
from diffusers.utils.state_dict_utils import convert_unet_state_dict_to_peft from diffusers.utils.state_dict_utils import convert_unet_state_dict_to_peft
from diffusers.loaders import FluxLoraLoaderMixin from diffusers.loaders import FluxLoraLoaderMixin # type: ignore[attr-defined]
if isinstance(input_lora, str): if isinstance(input_lora, str):
tensors = load_state_dict_in_safetensors(input_lora, device="cpu") tensors = load_state_dict_in_safetensors(input_lora, device="cpu")
@@ -97,10 +103,15 @@ def to_diffusers(input_lora):
return new_tensors return new_tensors
def nunchaku_load_lora(model, lora_name, lora_strength): def nunchaku_load_lora(model, lora_name, lora_strength):
"""Load a Flux LoRA for Nunchaku model""" """Load a Flux LoRA for Nunchaku model"""
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names. # Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name) lora_path = (
lora_name
if os.path.isfile(lora_name)
else folder_paths.get_full_path("loras", lora_name)
)
if not lora_path or not os.path.isfile(lora_path): if not lora_path or not os.path.isfile(lora_path):
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name) logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
return model return model
@@ -118,7 +129,9 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)] ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)]
else: else:
# Fallback to legacy logic # Fallback to legacy logic
logger.warning("Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic.") logger.warning(
"Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic."
)
transformer = model_wrapper.model transformer = model_wrapper.model
# Save the transformer temporarily # Save the transformer temporarily

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ objects that can be composed by the route controller.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import subprocess import subprocess
@@ -218,20 +219,149 @@ class HealthCheckHandler:
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok"})
class SupportersHandler:
"""Handler for supporters data."""
def __init__(self, logger: logging.Logger | None = None) -> None:
self._logger = logger or logging.getLogger(__name__)
def _load_supporters(self) -> dict:
"""Load supporters data from JSON file."""
try:
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
supporters_path = os.path.join(root_dir, "data", "supporters.json")
if os.path.exists(supporters_path):
with open(supporters_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
self._logger.debug(f"Failed to load supporters data: {e}")
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
async def get_supporters(self, request: web.Request) -> web.Response:
"""Return supporters data as JSON."""
try:
supporters = self._load_supporters()
return web.json_response({"success": True, "supporters": supporters})
except Exception as exc:
self._logger.error("Error loading supporters: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ExampleWorkflowsHandler:
"""Handler for example workflow templates."""
def __init__(self, logger: logging.Logger | None = None) -> None:
self._logger = logger or logging.getLogger(__name__)
def _get_workflows_dir(self) -> str:
"""Get the example workflows directory path."""
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
return os.path.join(root_dir, "example_workflows")
def _format_workflow_name(self, filename: str) -> str:
"""Convert filename to human-readable name."""
name = os.path.splitext(filename)[0]
name = name.replace("_", " ")
return name
async def get_example_workflows(self, request: web.Request) -> web.Response:
"""Return list of available example workflows."""
try:
workflows_dir = self._get_workflows_dir()
workflows = [
{
"value": "Default",
"label": "Default (Blank)",
"path": None,
}
]
if os.path.exists(workflows_dir):
for filename in sorted(os.listdir(workflows_dir)):
if filename.endswith(".json"):
workflows.append(
{
"value": filename,
"label": self._format_workflow_name(filename),
"path": f"example_workflows/{filename}",
}
)
return web.json_response({"success": True, "workflows": workflows})
except Exception as exc:
self._logger.error(
"Error listing example workflows: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_example_workflow(self, request: web.Request) -> web.Response:
"""Return a specific example workflow JSON content."""
try:
filename = request.match_info.get("filename")
if not filename:
return web.json_response(
{"success": False, "error": "Filename not provided"}, status=400
)
if filename == "Default":
return web.json_response(
{
"success": True,
"workflow": {
"last_node_id": 0,
"last_link_id": 0,
"nodes": [],
"links": [],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
},
}
)
workflows_dir = self._get_workflows_dir()
filepath = os.path.join(workflows_dir, filename)
if not os.path.exists(filepath):
return web.json_response(
{"success": False, "error": f"Workflow not found: {filename}"},
status=404,
)
with open(filepath, "r", encoding="utf-8") as f:
workflow = json.load(f)
return web.json_response({"success": True, "workflow": workflow})
except Exception as exc:
self._logger.error("Error loading example workflow: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class SettingsHandler: class SettingsHandler:
"""Sync settings between backend and frontend.""" """Sync settings between backend and frontend."""
# Settings keys that should NOT be synced to frontend. # Settings keys that should NOT be synced to frontend.
# All other settings are synced by default. # All other settings are synced by default.
_NO_SYNC_KEYS = frozenset({ _NO_SYNC_KEYS = frozenset(
# Internal/performance settings (not used by frontend) {
"hash_chunk_size_mb", # Internal/performance settings (not used by frontend)
"download_stall_timeout_seconds", "hash_chunk_size_mb",
# Complex internal structures retrieved via separate endpoints "download_stall_timeout_seconds",
"folder_paths", # Complex internal structures retrieved via separate endpoints
"libraries", "folder_paths",
"active_library", "libraries",
}) "active_library",
}
)
_PROXY_KEYS = { _PROXY_KEYS = {
"proxy_enabled", "proxy_enabled",
@@ -1186,6 +1316,7 @@ class CustomWordsHandler:
def __init__(self) -> None: def __init__(self) -> None:
from ...services.custom_words_service import get_custom_words_service from ...services.custom_words_service import get_custom_words_service
self._service = get_custom_words_service() self._service = get_custom_words_service()
async def search_custom_words(self, request: web.Request) -> web.Response: async def search_custom_words(self, request: web.Request) -> web.Response:
@@ -1194,6 +1325,7 @@ class CustomWordsHandler:
Query parameters: Query parameters:
search: The search term to match against. search: The search term to match against.
limit: Maximum number of results to return (default: 20). limit: Maximum number of results to return (default: 20).
offset: Number of results to skip (default: 0).
category: Optional category filter. Can be: category: Optional category filter. Can be:
- A category name (e.g., "character", "artist", "general") - A category name (e.g., "character", "artist", "general")
- Comma-separated category IDs (e.g., "4,11" for character) - Comma-separated category IDs (e.g., "4,11" for character)
@@ -1203,6 +1335,7 @@ class CustomWordsHandler:
try: try:
search_term = request.query.get("search", "") search_term = request.query.get("search", "")
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
offset = max(0, int(request.query.get("offset", "0")))
category_param = request.query.get("category", "") category_param = request.query.get("category", "")
enriched_param = request.query.get("enriched", "").lower() == "true" enriched_param = request.query.get("enriched", "").lower() == "true"
@@ -1212,13 +1345,14 @@ class CustomWordsHandler:
categories = self._parse_category_param(category_param) categories = self._parse_category_param(category_param)
results = self._service.search_words( results = self._service.search_words(
search_term, limit, categories=categories, enriched=enriched_param search_term,
limit,
offset=offset,
categories=categories,
enriched=enriched_param,
) )
return web.json_response({ return web.json_response({"success": True, "words": results})
"success": True,
"words": results
})
except Exception as exc: except Exception as exc:
logger.error("Error searching custom words: %s", exc, exc_info=True) logger.error("Error searching custom words: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
@@ -1482,6 +1616,8 @@ class MiscHandlerSet:
metadata_archive: MetadataArchiveHandler, metadata_archive: MetadataArchiveHandler,
filesystem: FileSystemHandler, filesystem: FileSystemHandler,
custom_words: CustomWordsHandler, custom_words: CustomWordsHandler,
supporters: SupportersHandler,
example_workflows: ExampleWorkflowsHandler,
) -> None: ) -> None:
self.health = health self.health = health
self.settings = settings self.settings = settings
@@ -1494,6 +1630,8 @@ class MiscHandlerSet:
self.metadata_archive = metadata_archive self.metadata_archive = metadata_archive
self.filesystem = filesystem self.filesystem = filesystem
self.custom_words = custom_words self.custom_words = custom_words
self.supporters = supporters
self.example_workflows = example_workflows
def to_route_mapping( def to_route_mapping(
self, self,
@@ -1522,6 +1660,9 @@ class MiscHandlerSet:
"open_file_location": self.filesystem.open_file_location, "open_file_location": self.filesystem.open_file_location,
"open_settings_location": self.filesystem.open_settings_location, "open_settings_location": self.filesystem.open_settings_location,
"search_custom_words": self.custom_words.search_custom_words, "search_custom_words": self.custom_words.search_custom_words,
"get_supporters": self.supporters.get_supporters,
"get_example_workflows": self.example_workflows.get_example_workflows,
"get_example_workflow": self.example_workflows.get_example_workflow,
} }

View File

@@ -66,6 +66,23 @@ class ModelPageView:
self._logger = logger self._logger = logger
self._app_version = self._get_app_version() self._app_version = self._get_app_version()
def _load_supporters(self) -> dict:
"""Load supporters data from JSON file."""
try:
current_file = os.path.abspath(__file__)
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(current_file)))
)
supporters_path = os.path.join(root_dir, "data", "supporters.json")
if os.path.exists(supporters_path):
with open(supporters_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
self._logger.debug(f"Failed to load supporters data: {e}")
return {"specialThanks": [], "allSupporters": [], "totalCount": 0}
def _get_app_version(self) -> str: def _get_app_version(self) -> str:
version = "1.0.0" version = "1.0.0"
short_hash = "stable" short_hash = "stable"
@@ -391,12 +408,18 @@ class ModelManagementHandler:
if not sha256 or hash_status != "completed": if not sha256 or hash_status != "completed":
# For checkpoints, calculate hash on-demand # For checkpoints, calculate hash on-demand
scanner = self._service.scanner scanner = self._service.scanner
if hasattr(scanner, 'calculate_hash_for_model'): if hasattr(scanner, "calculate_hash_for_model"):
self._logger.info(f"Lazy hash calculation triggered for {file_path}") self._logger.info(
f"Lazy hash calculation triggered for {file_path}"
)
sha256 = await scanner.calculate_hash_for_model(file_path) sha256 = await scanner.calculate_hash_for_model(file_path)
if not sha256: if not sha256:
return web.json_response( return web.json_response(
{"success": False, "error": "Failed to calculate SHA256 hash"}, status=500 {
"success": False,
"error": "Failed to calculate SHA256 hash",
},
status=500,
) )
# Update model_data with new hash # Update model_data with new hash
model_data["sha256"] = sha256 model_data["sha256"] = sha256
@@ -524,6 +547,153 @@ class ModelManagementHandler:
self._logger.error("Error replacing preview: %s", exc, exc_info=True) self._logger.error("Error replacing preview: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500) return web.Response(text=str(exc), status=500)
async def set_preview_from_url(self, request: web.Request) -> web.Response:
"""Set a preview image from a remote URL (e.g., CivitAI)."""
try:
from ...utils.civitai_utils import rewrite_preview_url
from ...services.downloader import get_downloader
data = await request.json()
model_path = data.get("model_path")
image_url = data.get("image_url")
nsfw_level = data.get("nsfw_level", 0)
if not model_path:
return web.json_response(
{"success": False, "error": "Model path is required"}, status=400
)
if not image_url:
return web.json_response(
{"success": False, "error": "Image URL is required"}, status=400
)
# Rewrite URL to use optimized rendition if it's a Civitai URL
optimized_url, was_rewritten = rewrite_preview_url(
image_url, media_type="image"
)
if was_rewritten and optimized_url:
self._logger.info(
f"Rewritten preview URL to optimized version: {optimized_url}"
)
else:
optimized_url = image_url
# Download the image using the Downloader service
self._logger.info(
f"Downloading preview from {optimized_url} for {model_path}"
)
downloader = await get_downloader()
success, preview_data, headers = await downloader.download_to_memory(
optimized_url, use_auth=False, return_headers=True
)
if not success:
return web.json_response(
{
"success": False,
"error": f"Failed to download image: {preview_data}",
},
status=502,
)
# preview_data is bytes when success is True
preview_bytes = (
preview_data
if isinstance(preview_data, bytes)
else preview_data.encode("utf-8")
)
# Determine content type from response headers
content_type = (
headers.get("Content-Type", "image/jpeg") if headers else "image/jpeg"
)
# Extract original filename from URL
original_filename = None
if "?" in image_url:
url_path = image_url.split("?")[0]
else:
url_path = image_url
original_filename = url_path.split("/")[-1] if "/" in url_path else None
result = await self._preview_service.replace_preview(
model_path=model_path,
preview_data=preview_data,
content_type=content_type,
original_filename=original_filename,
nsfw_level=nsfw_level,
update_preview_in_cache=self._service.scanner.update_preview_in_cache,
metadata_loader=self._metadata_sync.load_local_metadata,
)
return web.json_response(
{
"success": True,
"preview_url": config.get_preview_static_url(
result["preview_path"]
),
"preview_nsfw_level": result["preview_nsfw_level"],
}
)
except Exception as exc:
self._logger.error("Error setting preview from URL: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
if not image_url:
return web.json_response(
{"success": False, "error": "Image URL is required"}, status=400
)
# Download the image from the remote URL
self._logger.info(f"Downloading preview from {image_url} for {model_path}")
async with aiohttp.ClientSession() as session:
async with session.get(image_url) as response:
if response.status != 200:
return web.json_response(
{
"success": False,
"error": f"Failed to download image: HTTP {response.status}",
},
status=502,
)
content_type = response.headers.get("Content-Type", "image/jpeg")
preview_data = await response.read()
# Extract original filename from URL
original_filename = None
if "?" in image_url:
url_path = image_url.split("?")[0]
else:
url_path = image_url
original_filename = (
url_path.split("/")[-1] if "/" in url_path else None
)
result = await self._preview_service.replace_preview(
model_path=model_path,
preview_data=preview_bytes,
content_type=content_type,
original_filename=original_filename,
nsfw_level=nsfw_level,
update_preview_in_cache=self._service.scanner.update_preview_in_cache,
metadata_loader=self._metadata_sync.load_local_metadata,
)
return web.json_response(
{
"success": True,
"preview_url": config.get_preview_static_url(
result["preview_path"]
),
"preview_nsfw_level": result["preview_nsfw_level"],
}
)
except Exception as exc:
self._logger.error("Error setting preview from URL: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def save_metadata(self, request: web.Request) -> web.Response: async def save_metadata(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
@@ -814,9 +984,7 @@ class ModelQueryHandler:
# Format response # Format response
group = {"hash": sha256, "models": []} group = {"hash": sha256, "models": []}
for model in sorted_models: for model in sorted_models:
group["models"].append( group["models"].append(await self._service.format_response(model))
await self._service.format_response(model)
)
# Only include groups with 2+ models after filtering # Only include groups with 2+ models after filtering
if len(group["models"]) > 1: if len(group["models"]) > 1:
@@ -845,7 +1013,9 @@ class ModelQueryHandler:
"favorites_only": request.query.get("favorites_only", "").lower() == "true", "favorites_only": request.query.get("favorites_only", "").lower() == "true",
} }
def _apply_duplicate_filters(self, models: List[Dict[str, Any]], filters: Dict[str, Any]) -> List[Dict[str, Any]]: def _apply_duplicate_filters(
self, models: List[Dict[str, Any]], filters: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Apply filters to a list of models within a duplicate group.""" """Apply filters to a list of models within a duplicate group."""
result = models result = models
@@ -886,7 +1056,9 @@ class ModelQueryHandler:
return result return result
def _sort_duplicate_group(self, models: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def _sort_duplicate_group(
self, models: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Sort models: originals first (left), copies (with -????. pattern) last (right).""" """Sort models: originals first (left), copies (with -????. pattern) last (right)."""
if len(models) <= 1: if len(models) <= 1:
return models return models
@@ -1096,8 +1268,11 @@ class ModelQueryHandler:
async def get_relative_paths(self, request: web.Request) -> web.Response: async def get_relative_paths(self, request: web.Request) -> web.Response:
try: try:
search = request.query.get("search", "").strip() search = request.query.get("search", "").strip()
limit = min(int(request.query.get("limit", "15")), 50) limit = min(int(request.query.get("limit", "15")), 100)
matching_paths = await self._service.search_relative_paths(search, limit) offset = max(0, int(request.query.get("offset", "0")))
matching_paths = await self._service.search_relative_paths(
search, limit, offset
)
return web.json_response( return web.json_response(
{"success": True, "relative_paths": matching_paths} {"success": True, "relative_paths": matching_paths}
) )
@@ -1171,10 +1346,13 @@ class ModelDownloadHandler:
data["source"] = source data["source"] = source
if file_params_json: if file_params_json:
import json import json
try: try:
data["file_params"] = json.loads(file_params_json) data["file_params"] = json.loads(file_params_json)
except json.JSONDecodeError: except json.JSONDecodeError:
self._logger.warning("Invalid file_params JSON: %s", file_params_json) self._logger.warning(
"Invalid file_params JSON: %s", file_params_json
)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
future = loop.create_future() future = loop.create_future()
@@ -1905,7 +2083,8 @@ class ModelUpdateHandler:
from dataclasses import replace from dataclasses import replace
new_record = replace( new_record = replace(
record, versions=list(version_map.values()), record,
versions=list(version_map.values()),
) )
# Optionally persist to database for caching # Optionally persist to database for caching
@@ -2120,6 +2299,7 @@ class ModelUpdateHandler:
if version.early_access_ends_at: if version.early_access_ends_at:
try: try:
from datetime import datetime, timezone from datetime import datetime, timezone
ea_date = datetime.fromisoformat( ea_date = datetime.fromisoformat(
version.early_access_ends_at.replace("Z", "+00:00") version.early_access_ends_at.replace("Z", "+00:00")
) )
@@ -2127,7 +2307,7 @@ class ModelUpdateHandler:
except (ValueError, AttributeError): except (ValueError, AttributeError):
# If date parsing fails, treat as active EA (conservative) # If date parsing fails, treat as active EA (conservative)
is_early_access = True is_early_access = True
elif getattr(version, 'is_early_access', False): elif getattr(version, "is_early_access", False):
# Fallback to basic EA flag from bulk API # Fallback to basic EA flag from bulk API
is_early_access = True is_early_access = True
@@ -2207,6 +2387,7 @@ class ModelHandlerSet:
"fetch_all_civitai": self.civitai.fetch_all_civitai, "fetch_all_civitai": self.civitai.fetch_all_civitai,
"relink_civitai": self.management.relink_civitai, "relink_civitai": self.management.relink_civitai,
"replace_preview": self.management.replace_preview, "replace_preview": self.management.replace_preview,
"set_preview_from_url": self.management.set_preview_from_url,
"save_metadata": self.management.save_metadata, "save_metadata": self.management.save_metadata,
"add_tags": self.management.add_tags, "add_tags": self.management.add_tags,
"rename_model": self.management.rename_model, "rename_model": self.management.rename_model,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import re
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
import logging import logging
import os import os
@@ -383,7 +384,9 @@ class BaseModelService(ABC):
# Check user setting for hiding early access updates # Check user setting for hiding early access updates
hide_early_access = False hide_early_access = False
try: 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: except Exception:
hide_early_access = False hide_early_access = False
@@ -413,7 +416,11 @@ class BaseModelService(ABC):
bulk_method = getattr(self.update_service, "has_updates_bulk", None) bulk_method = getattr(self.update_service, "has_updates_bulk", None)
if callable(bulk_method): if callable(bulk_method):
try: try:
resolved = await bulk_method(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: except Exception as exc:
logger.error( logger.error(
"Failed to resolve update status in bulk for %s models (%s): %s", "Failed to resolve update status in bulk for %s models (%s): %s",
@@ -426,7 +433,9 @@ class BaseModelService(ABC):
if resolved is None: if resolved is None:
tasks = [ 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 for model_id in ordered_ids
] ]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -590,9 +599,15 @@ class BaseModelService(ABC):
continue continue
# Filter by valid sub-types based on scanner type # 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 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 continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1 type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
@@ -807,38 +822,61 @@ class BaseModelService(ABC):
return include_terms, exclude_terms return include_terms, exclude_terms
@staticmethod
def _remove_model_extension(path: str) -> str:
"""Remove model file extension (.safetensors, .ckpt, .pt, .bin) for cleaner matching."""
return re.sub(r"\.(safetensors|ckpt|pt|bin)$", "", path, flags=re.IGNORECASE)
@staticmethod @staticmethod
def _relative_path_matches_tokens( def _relative_path_matches_tokens(
path_lower: str, include_terms: List[str], exclude_terms: List[str] path_lower: str, include_terms: List[str], exclude_terms: List[str]
) -> bool: ) -> bool:
"""Determine whether a relative path string satisfies include/exclude tokens.""" """Determine whether a relative path string satisfies include/exclude tokens.
if any(term and term in path_lower for term in exclude_terms):
Matches against the path without extension to avoid matching .safetensors
when searching for 's'.
"""
# Use path without extension for matching
path_for_matching = BaseModelService._remove_model_extension(path_lower)
if any(term and term in path_for_matching for term in exclude_terms):
return False return False
for term in include_terms: for term in include_terms:
if term and term not in path_lower: if term and term not in path_for_matching:
return False return False
return True return True
@staticmethod @staticmethod
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple: def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
"""Sort paths by how well they satisfy the include tokens.""" """Sort paths by how well they satisfy the include tokens.
path_lower = relative_path.lower()
Sorts based on path without extension for consistent ordering.
"""
# Use path without extension for sorting
path_for_sorting = BaseModelService._remove_model_extension(
relative_path.lower()
)
prefix_hits = sum( prefix_hits = sum(
1 for term in include_terms if term and path_lower.startswith(term) 1 for term in include_terms if term and path_for_sorting.startswith(term)
) )
match_positions = [ match_positions = [
path_lower.find(term) path_for_sorting.find(term)
for term in include_terms 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 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( async def search_relative_paths(
self, search_term: str, limit: int = 15 self, search_term: str, limit: int = 15, offset: int = 0
) -> List[str]: ) -> List[str]:
"""Search model relative file paths for autocomplete functionality""" """Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -849,6 +887,7 @@ class BaseModelService(ABC):
# Get model roots for path calculation # Get model roots for path calculation
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset)
for model in cache.raw_data: for model in cache.raw_data:
file_path = model.get("file_path", "") file_path = model.get("file_path", "")
if not file_path: if not file_path:
@@ -877,12 +916,12 @@ class BaseModelService(ABC):
): ):
matching_paths.append(relative_path) matching_paths.append(relative_path)
if len(matching_paths) >= limit * 2: # Get more for better sorting
break
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically) # Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
matching_paths.sort( matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms) key=lambda relative: self._relative_path_sort_key(relative, include_terms)
) )
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]

View 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]

View File

@@ -125,16 +125,20 @@ class CacheEntryValidator:
) )
# Special validation: sha256 must not be empty for required field # 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', '') sha256 = working_entry.get('sha256', '')
hash_status = working_entry.get('hash_status', 'completed')
if not sha256 or (isinstance(sha256, str) and not sha256.strip()): if not sha256 or (isinstance(sha256, str) and not sha256.strip()):
errors.append("Required field 'sha256' is empty") # Allow empty sha256 for lazy hash calculation (checkpoints)
# Cannot repair empty sha256 - entry is invalid if hash_status != 'pending':
return ValidationResult( errors.append("Required field 'sha256' is empty")
is_valid=False, # Cannot repair empty sha256 - entry is invalid
repaired=repaired, return ValidationResult(
errors=errors, is_valid=False,
entry=working_entry if auto_repair else None repaired=repaired,
) errors=errors,
entry=working_entry if auto_repair else None
)
# Normalize sha256 to lowercase if needed # Normalize sha256 to lowercase if needed
if isinstance(sha256, str): if isinstance(sha256, str):

View File

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

View File

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

View File

@@ -10,7 +10,11 @@ import uuid
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
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.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media from ..utils.preview_selection import select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import sanitize_folder_name
@@ -352,10 +356,12 @@ class DownloadManager:
# Check if this checkpoint should be treated as a diffusion model based on baseModel # Check if this checkpoint should be treated as a diffusion model based on baseModel
is_diffusion_model = False is_diffusion_model = False
if model_type == "checkpoint": if model_type == "checkpoint":
base_model_value = version_info.get('baseModel', '') base_model_value = version_info.get("baseModel", "")
if base_model_value in DIFFUSION_MODEL_BASE_MODELS: if base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True is_diffusion_model = True
logger.info(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 # Case 2: model_version_id was None, check after getting version_info
if model_version_id is None: if model_version_id is None:
@@ -476,8 +482,13 @@ class DownloadManager:
if is_primary: if is_primary:
# Find primary file # Find primary file
file_info = next( 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: else:
# Match by metadata # Match by metadata
@@ -1220,7 +1231,13 @@ class DownloadManager:
entries: List = [] entries: List = []
for index, file_path in enumerate(file_paths): for index, file_path in enumerate(file_paths):
entry = base_metadata if index == 0 else copy.deepcopy(base_metadata) entry = base_metadata if index == 0 else copy.deepcopy(base_metadata)
entry.update_file_info(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)
entry.sha256 = await calculate_sha256(file_path) entry.sha256 = await calculate_sha256(file_path)
entries.append(entry) entries.append(entry)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -47,8 +47,7 @@ class BulkMetadataRefreshUseCase:
to_process: Sequence[Dict[str, Any]] = [ to_process: Sequence[Dict[str, Any]] = [
model model
for model in cache.raw_data for model in cache.raw_data
if model.get("sha256") if not model.get("skip_metadata_refresh", False)
and not model.get("skip_metadata_refresh", False)
and not self._is_in_skip_path(model.get("folder", ""), skip_paths) and not self._is_in_skip_path(model.get("folder", ""), skip_paths)
and (not model.get("civitai") or not model["civitai"].get("id")) and (not model.get("civitai") or not model["civitai"].get("id"))
and not ( and not (
@@ -85,6 +84,36 @@ class BulkMetadataRefreshUseCase:
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models} return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
try: try:
original_name = model.get("model_name") original_name = model.get("model_name")
# Handle lazy hash calculation for models with pending hash status
sha256 = model.get("sha256", "")
hash_status = model.get("hash_status", "completed")
file_path = model.get("file_path")
if not sha256 and hash_status == "pending" and file_path:
self._logger.info(f"Calculating pending hash for {file_path}")
# Check if scanner has calculate_hash_for_model method (CheckpointScanner)
calculate_hash_method = getattr(self._service.scanner, "calculate_hash_for_model", None)
if calculate_hash_method:
sha256 = await calculate_hash_method(file_path)
if sha256:
model["sha256"] = sha256
model["hash_status"] = "completed"
else:
self._logger.error(f"Failed to calculate hash for {file_path}")
processed += 1
continue
else:
self._logger.warning(f"Scanner does not support lazy hash calculation for {file_path}")
processed += 1
continue
# Skip models without valid hash
if not model.get("sha256"):
self._logger.warning(f"Skipping model without hash: {file_path}")
processed += 1
continue
await MetadataManager.hydrate_model_data(model) await MetadataManager.hydrate_model_data(model)
result, _ = await self._metadata_sync.fetch_and_update_model( result, _ = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"], sha256=model["sha256"],

View File

@@ -4,32 +4,40 @@ from datetime import datetime
import os import os
from .model_utils import determine_base_model from .model_utils import determine_base_model
@dataclass @dataclass
class BaseModelMetadata: class BaseModelMetadata:
"""Base class for all model metadata structures""" """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_name: str # The filename without extension
file_path: str # Full path to the model file model_name: str # The model's name defined by the creator
size: int # File size in bytes file_path: str # Full path to the model file
modified: float # Timestamp when the model was added to the management system size: int # File size in bytes
sha256: str # SHA256 hash of the file modified: float # Timestamp when the model was added to the management system
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.) sha256: str # SHA256 hash of the file
preview_url: str # Preview image URL base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
preview_nsfw_level: int = 0 # NSFW level of the preview image preview_url: str # Preview image URL
notes: str = "" # Additional notes preview_nsfw_level: int = 0 # NSFW level of the preview image
from_civitai: bool = True # Whether from Civitai notes: str = "" # Additional notes
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available from_civitai: bool = True # Whether from Civitai
tags: List[str] = None # Model tags civitai: Dict[str, Any] = field(
default_factory=dict
) # Civitai API data if available
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache exclude: bool = False # Whether to exclude this model from the cache
db_checked: bool = False # Whether checked in archive DB db_checked: bool = False # Whether checked in archive DB
skip_metadata_refresh: bool = False # Whether to skip this model during bulk metadata refresh skip_metadata_refresh: bool = (
False # Whether to skip this model during bulk metadata refresh
)
metadata_source: Optional[str] = None # Last provider that supplied metadata metadata_source: Optional[str] = None # Last provider that supplied metadata
last_checked_at: float = 0 # Last checked timestamp last_checked_at: float = 0 # Last checked timestamp
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed 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): def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue # Initialize empty lists to avoid mutable default parameter issue
@@ -40,15 +48,15 @@ class BaseModelMetadata:
self.tags = [] self.tags = []
@classmethod @classmethod
def from_dict(cls, data: Dict) -> 'BaseModelMetadata': def from_dict(cls, data: Dict) -> "BaseModelMetadata":
"""Create instance from dictionary""" """Create instance from dictionary"""
data_copy = data.copy() data_copy = data.copy()
# Use cached fields if available, otherwise compute them # 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() known_fields = set()
for c in cls.mro(): for c in cls.mro():
if hasattr(c, '__annotations__'): if hasattr(c, "__annotations__"):
known_fields.update(c.__annotations__.keys()) known_fields.update(c.__annotations__.keys())
cls._known_fields_cache = known_fields cls._known_fields_cache = known_fields
@@ -58,7 +66,11 @@ class BaseModelMetadata:
fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields} fields_to_use = {k: v for k, v in data_copy.items() if k in known_fields}
# Store unknown fields separately # 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 # Create instance with known fields
instance = cls(**fields_to_use) instance = cls(**fields_to_use)
@@ -73,10 +85,10 @@ class BaseModelMetadata:
result = asdict(self) result = asdict(self)
# Remove private fields # 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 # Add back unknown fields if they exist
if hasattr(self, '_unknown_fields'): if hasattr(self, "_unknown_fields"):
result.update(self._unknown_fields) result.update(self._unknown_fields)
return result return result
@@ -85,17 +97,29 @@ class BaseModelMetadata:
"""Update Civitai information""" """Update Civitai information"""
self.civitai = civitai_data self.civitai = civitai_data
def update_file_info(self, file_path: str) -> None: def update_file_info(self, file_path: str, update_timestamps: bool = False) -> None:
"""Update metadata with actual file information""" """
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): if os.path.exists(file_path):
self.size = os.path.getsize(file_path) if update_timestamps:
self.modified = os.path.getmtime(file_path) # Only update size and modified when file has been relocated
self.file_path = file_path.replace(os.sep, '/') self.size = os.path.getsize(file_path)
# Update file_name when file_path changes 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] self.file_name = os.path.splitext(os.path.basename(file_path))[0]
@staticmethod @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 """Generate a unique filename to avoid conflicts
Args: Args:
@@ -136,115 +160,126 @@ class BaseModelMetadata:
return unique_filename return unique_filename
@dataclass @dataclass
class LoraMetadata(BaseModelMetadata): class LoraMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Lora model""" """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 @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""" """Create LoraMetadata instance from Civitai version info"""
file_name = file_info.get('name', '') file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get('baseModel', '')) base_model = determine_base_model(version_info.get("baseModel", ""))
# Extract tags and description if available # Extract tags and description if available
tags = [] tags = []
description = "" description = ""
model_data = version_info.get('model') or {} model_data = version_info.get("model") or {}
if 'tags' in model_data: if "tags" in model_data:
tags = model_data['tags'] tags = model_data["tags"]
if 'description' in model_data: if "description" in model_data:
description = model_data['description'] description = model_data["description"]
return cls( return cls(
file_name=os.path.splitext(file_name)[0], file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('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, '/'), file_path=save_path.replace(os.sep, "/"),
size=file_info.get('sizeKB', 0) * 1024, size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(), 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, base_model=base_model,
preview_url='', # Will be updated after preview download preview_url="", # Will be updated after preview download
preview_nsfw_level=0, # Will be updated after preview download preview_nsfw_level=0, # Will be updated after preview download
from_civitai=True, from_civitai=True,
civitai=version_info, civitai=version_info,
tags=tags, tags=tags,
modelDescription=description modelDescription=description,
) )
@dataclass @dataclass
class CheckpointMetadata(BaseModelMetadata): class CheckpointMetadata(BaseModelMetadata):
"""Represents the metadata structure for a Checkpoint model""" """Represents the metadata structure for a Checkpoint model"""
sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.) sub_type: str = "checkpoint" # Model sub-type (checkpoint, diffusion_model, etc.)
@classmethod @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""" """Create CheckpointMetadata instance from Civitai version info"""
file_name = file_info.get('name', '') file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get('baseModel', '')) base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get('type', 'checkpoint') sub_type = version_info.get("type", "checkpoint")
# Extract tags and description if available # Extract tags and description if available
tags = [] tags = []
description = "" description = ""
model_data = version_info.get('model') or {} model_data = version_info.get("model") or {}
if 'tags' in model_data: if "tags" in model_data:
tags = model_data['tags'] tags = model_data["tags"]
if 'description' in model_data: if "description" in model_data:
description = model_data['description'] description = model_data["description"]
return cls( return cls(
file_name=os.path.splitext(file_name)[0], file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('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, '/'), file_path=save_path.replace(os.sep, "/"),
size=file_info.get('sizeKB', 0) * 1024, size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(), 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, base_model=base_model,
preview_url='', # Will be updated after preview download preview_url="", # Will be updated after preview download
preview_nsfw_level=0, preview_nsfw_level=0,
from_civitai=True, from_civitai=True,
civitai=version_info, civitai=version_info,
sub_type=sub_type, sub_type=sub_type,
tags=tags, tags=tags,
modelDescription=description modelDescription=description,
) )
@dataclass @dataclass
class EmbeddingMetadata(BaseModelMetadata): class EmbeddingMetadata(BaseModelMetadata):
"""Represents the metadata structure for an Embedding model""" """Represents the metadata structure for an Embedding model"""
sub_type: str = "embedding" sub_type: str = "embedding"
@classmethod @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""" """Create EmbeddingMetadata instance from Civitai version info"""
file_name = file_info.get('name', '') file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get('baseModel', '')) base_model = determine_base_model(version_info.get("baseModel", ""))
sub_type = version_info.get('type', 'embedding') sub_type = version_info.get("type", "embedding")
# Extract tags and description if available # Extract tags and description if available
tags = [] tags = []
description = "" description = ""
model_data = version_info.get('model') or {} model_data = version_info.get("model") or {}
if 'tags' in model_data: if "tags" in model_data:
tags = model_data['tags'] tags = model_data["tags"]
if 'description' in model_data: if "description" in model_data:
description = model_data['description'] description = model_data["description"]
return cls( return cls(
file_name=os.path.splitext(file_name)[0], file_name=os.path.splitext(file_name)[0],
model_name=model_data.get('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, '/'), file_path=save_path.replace(os.sep, "/"),
size=file_info.get('sizeKB', 0) * 1024, size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(), 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, base_model=base_model,
preview_url='', # Will be updated after preview download preview_url="", # Will be updated after preview download
preview_nsfw_level=0, preview_nsfw_level=0,
from_civitai=True, from_civitai=True,
civitai=version_info, civitai=version_info,
sub_type=sub_type, sub_type=sub_type,
tags=tags, tags=tags,
modelDescription=description modelDescription=description,
) )

View File

@@ -7,24 +7,38 @@ from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
import asyncio import asyncio
def get_lora_info(lora_name): def get_lora_info(lora_name):
"""Get the lora path and trigger words from cache""" """Get the lora path and trigger words from cache"""
async def _get_lora_info_async(): async def _get_lora_info_async():
scanner = await ServiceRegistry.get_lora_scanner() scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
for item in cache.raw_data: for item in cache.raw_data:
if item.get('file_name') == lora_name: if item.get("file_name") == lora_name:
file_path = item.get('file_path') file_path = item.get("file_path")
if file_path: if file_path:
for root in config.loras_roots: # Check all lora roots including extra paths
root = root.replace(os.sep, '/') 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): 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 # Get trigger words from civitai metadata
civitai = item.get('civitai', {}) civitai = item.get("civitai", {})
trigger_words = civitai.get('trainedWords', []) if civitai else [] trigger_words = (
civitai.get("trainedWords", []) if civitai else []
)
return relative_path, trigger_words 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, [] return lora_name, []
try: try:
@@ -58,18 +72,19 @@ def get_lora_info_absolute(lora_name):
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 file system path to the LoRA file, or original lora_name if not found
""" """
async def _get_lora_info_absolute_async(): async def _get_lora_info_absolute_async():
scanner = await ServiceRegistry.get_lora_scanner() scanner = await ServiceRegistry.get_lora_scanner()
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
for item in cache.raw_data: for item in cache.raw_data:
if item.get('file_name') == lora_name: if item.get("file_name") == lora_name:
file_path = item.get('file_path') file_path = item.get("file_path")
if file_path: if file_path:
# Return absolute path directly # Return absolute path directly
# Get trigger words from civitai metadata # Get trigger words from civitai metadata
civitai = item.get('civitai', {}) civitai = item.get("civitai", {})
trigger_words = civitai.get('trainedWords', []) if civitai else [] trigger_words = civitai.get("trainedWords", []) if civitai else []
return file_path, trigger_words return file_path, trigger_words
return lora_name, [] return lora_name, []
@@ -96,41 +111,43 @@ def get_lora_info_absolute(lora_name):
# No event loop is running, we can use asyncio.run() # No event loop is running, we can use asyncio.run()
return asyncio.run(_get_lora_info_absolute_async()) return asyncio.run(_get_lora_info_absolute_async())
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
""" """
Check if text matches pattern using fuzzy matching. Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold. Returns True if similarity ratio is above threshold.
""" """
if not pattern or not text: 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 return False
# Convert both to lowercase for case-insensitive matching # All words found either as substrings or fuzzy matches
text = text.lower() return True
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: def sanitize_folder_name(name: str, replacement: str = "_") -> str:
"""Sanitize a folder name by removing or replacing invalid characters. """Sanitize a folder name by removing or replacing invalid characters.
@@ -156,10 +173,13 @@ def sanitize_folder_name(name: str, replacement: str = "_") -> str:
# Collapse repeated replacement characters to a single instance # Collapse repeated replacement characters to a single instance
if replacement: if replacement:
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized) sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
sanitized = sanitized.strip(replacement) # Combine stripping to be idempotent:
# Right side: strip replacement, space, and dot (Windows restriction)
# Remove trailing spaces or periods which are invalid on Windows # Left side: strip replacement and space (leading dots are allowed)
sanitized = sanitized.rstrip(" .") 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: if not sanitized:
return "unnamed" return "unnamed"
@@ -213,11 +233,16 @@ def calculate_recipe_fingerprint(loras):
valid_loras.sort() valid_loras.sort()
# Join in format hash1:strength1|hash2:strength2|... # 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 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 """Calculate relative path for existing model using template from settings
Args: Args:
@@ -233,54 +258,57 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
# If template is empty, return empty path (flat structure) # If template is empty, return empty path (flat structure)
if not path_template: if not path_template:
return '' return ""
# Get base model name from model metadata # 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 # 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: if civitai_data and civitai_data.get("id") is not None:
base_model = model_data.get('base_model', '') base_model = model_data.get("base_model", "")
# Get author from civitai creator data # Get author from civitai creator data
creator_info = civitai_data.get('creator') or {} creator_info = civitai_data.get("creator") or {}
author = creator_info.get('username') or 'Anonymous' author = creator_info.get("username") or "Anonymous"
else: else:
# Fallback to model_data fields for non-CivitAI models # Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '') base_model = model_data.get("base_model", "")
author = 'Anonymous' # Default for non-CivitAI models author = "Anonymous" # Default for non-CivitAI models
model_tags = model_data.get('tags', []) model_tags = model_data.get("tags", [])
# Apply mapping if available # 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) mapped_base_model = base_model_mappings.get(base_model, base_model)
# Convert all tags to lowercase to avoid case sensitivity issues on Windows # 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)] 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: 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 # Format the template with available data
model_name = sanitize_folder_name(model_data.get('model_name', '')) model_name = sanitize_folder_name(model_data.get("model_name", ""))
version_name = '' version_name = ""
if isinstance(civitai_data, dict): 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 = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace("{base_model}", mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag) formatted_path = formatted_path.replace("{first_tag}", first_tag)
formatted_path = formatted_path.replace('{author}', author) formatted_path = formatted_path.replace("{author}", author)
formatted_path = formatted_path.replace('{model_name}', model_name) formatted_path = formatted_path.replace("{model_name}", model_name)
formatted_path = formatted_path.replace('{version_name}', version_name) formatted_path = formatted_path.replace("{version_name}", version_name)
if model_type == 'embedding': if model_type == "embedding":
formatted_path = formatted_path.replace(' ', '_') formatted_path = formatted_path.replace(" ", "_")
return formatted_path return formatted_path
def remove_empty_dirs(path): def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path. """Recursively remove empty directories starting from the given path.

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.16" version = "1.0.0"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -1,5 +1,5 @@
[pytest] [pytest]
addopts = -v --import-mode=importlib addopts = -v --import-mode=importlib -m "not performance"
testpaths = tests testpaths = tests
python_files = test_*.py python_files = test_*.py
python_classes = Test* python_classes = Test*
@@ -12,5 +12,6 @@ markers =
asyncio: execute test within asyncio event loop asyncio: execute test within asyncio event loop
no_settings_dir_isolation: allow tests to use real settings paths no_settings_dir_isolation: allow tests to use real settings paths
integration: integration tests requiring external resources integration: integration tests requiring external resources
performance: performance benchmarks (slow, skip by default)
# Skip problematic directories to avoid import conflicts # Skip problematic directories to avoid import conflicts
norecursedirs = .git .tox dist build *.egg __pycache__ py .hypothesis norecursedirs = .git .tox dist build *.egg __pycache__ py .hypothesis

View File

@@ -0,0 +1,63 @@
import json
import os
import re
def update_readme():
# 1. Read JSON data
json_path = 'data/supporters.json'
if not os.path.exists(json_path):
print(f"Error: {json_path} not found.")
return
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 2. Generate Markdown content
special_thanks = data.get('specialThanks', [])
all_supporters = data.get('allSupporters', [])
total_count = data.get('totalCount', len(all_supporters))
md_content = "\n### 🌟 Special Thanks\n\n"
if special_thanks:
md_content += ", ".join([f"**{name}**" for name in special_thanks]) + "\n\n"
else:
md_content += "*None yet*\n\n"
md_content += f"### 💖 Supporters ({total_count})\n\n"
if all_supporters:
# Using a details block for the long list of supporters
md_content += "<details>\n<summary>Click to view all awesome supporters</summary>\n<br>\n\n"
md_content += ", ".join(all_supporters)
md_content += "\n\n</details>\n"
else:
md_content += "*No supporters listed yet*\n"
# 3. Read existing README.md
readme_path = 'README.md'
with open(readme_path, 'r', encoding='utf-8') as f:
readme = f.read()
# 4. Replace content between placeholders
start_tag = '<!-- SUPPORTERS-START -->'
end_tag = '<!-- SUPPORTERS-END -->'
if start_tag not in readme or end_tag not in readme:
print(f"Error: Placeholders {start_tag} and {end_tag} not found in {readme_path}")
return
# Using non-regex replacement to avoid issues with special characters in names
parts = readme.split(start_tag)
before_start = parts[0]
after_start = parts[1].split(end_tag)
after_end = after_start[1]
new_readme = f"{before_start}{start_tag}\n{md_content}\n{end_tag}{after_end}"
# 5. Write back to README.md
with open(readme_path, 'w', encoding='utf-8') as f:
f.write(new_readme)
print(f"Successfully updated {readme_path} with {len(all_supporters)} supporters!")
if __name__ == '__main__':
update_readme()

View File

@@ -345,6 +345,7 @@ class StandaloneLoraManager(LoraManager):
"/ws/download-progress", ws_manager.handle_download_connection "/ws/download-progress", ws_manager.handle_download_connection
) )
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection) app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
app.router.add_get("/ws/batch-import-progress", ws_manager.handle_connection)
# Schedule service initialization # Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services()) app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -68,6 +68,7 @@ body {
--space-1: calc(8px * 1); --space-1: calc(8px * 1);
--space-2: calc(8px * 2); --space-2: calc(8px * 2);
--space-3: calc(8px * 3); --space-3: calc(8px * 3);
--space-4: calc(8px * 4);
/* Z-index Scale */ /* Z-index Scale */
--z-base: 10; --z-base: 10;
@@ -77,6 +78,7 @@ body {
/* Border Radius */ /* Border Radius */
--border-radius-base: 12px; --border-radius-base: 12px;
--border-radius-md: 12px;
--border-radius-sm: 8px; --border-radius-sm: 8px;
--border-radius-xs: 4px; --border-radius-xs: 4px;

View 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';
}

View File

@@ -130,7 +130,7 @@
max-height: 400px; max-height: 400px;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1000; z-index: var(--z-overlay);
display: none; display: none;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }

View File

@@ -1,6 +1,26 @@
/* Support Modal Styles */ /* Support Modal Styles */
.support-modal { .support-modal {
max-width: 570px; max-width: 1000px;
width: 90vw;
}
/* Two-column layout */
.support-container {
display: flex;
gap: var(--space-3);
min-height: 500px;
}
.support-left {
flex: 0 0 42%;
min-width: 0;
}
.support-right {
flex: 1;
min-width: 0;
border-left: 1px solid var(--lora-border);
padding-left: var(--space-4);
} }
.support-header { .support-header {
@@ -214,6 +234,11 @@
.support-links { .support-links {
flex-direction: column; flex-direction: column;
} }
.support-modal {
width: 95vw;
max-width: 95vw;
}
} }
/* Civitai link styles */ /* Civitai link styles */
@@ -240,3 +265,222 @@
border-color: var(--lora-accent); border-color: var(--lora-accent);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
/* Supporters Section Styles */
.supporters-section {
height: 100%;
display: flex;
flex-direction: column;
}
.supporters-header {
margin-bottom: var(--space-4);
}
.supporters-title {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-1) 0;
font-size: 1.3em !important;
color: var(--lora-accent) !important;
}
.supporters-title i {
opacity: 0.9;
}
.supporters-subtitle {
margin: 0;
font-size: 0.95em;
color: var(--text-color);
opacity: 0.6;
}
.supporters-group {
margin-bottom: var(--space-3);
}
.supporters-group-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 var(--space-2) 0;
font-size: 1em;
color: var(--text-color);
opacity: 0.8;
font-weight: 500;
}
.supporters-group-title i {
color: var(--lora-accent);
opacity: 0.7;
}
/* Special Thanks - Clean Card Style */
.special-thanks-group {
margin-bottom: var(--space-4);
}
.special-thanks-group .supporters-group-title {
margin-bottom: var(--space-3);
}
.special-thanks-group .supporters-group-title i {
color: #fbbf24;
}
.all-supporters-group .supporters-group-title i {
color: var(--lora-error);
opacity: 0.9;
}
.supporters-special-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
}
.supporter-special-card {
display: flex;
align-items: center;
padding: var(--space-2) var(--space-3);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-left: 3px solid var(--lora-accent);
border-radius: var(--border-radius-sm);
transition: all 0.2s ease;
cursor: default;
}
.supporter-special-card:hover {
border-color: var(--lora-accent);
border-left-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateX(4px);
}
.supporter-special-card .supporter-special-name {
font-size: 1em;
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.supporter-special-card:hover .supporter-special-name {
color: var(--lora-accent);
}
/* All Supporters - Elegant Text Flow */
.all-supporters-group {
flex: 1;
display: flex;
flex-direction: column;
position: relative; /* Base for masks */
}
/* Optional: Fading effect for credits feel at top and bottom */
.all-supporters-group::before,
.all-supporters-group::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 40px;
pointer-events: none;
z-index: 2;
}
.all-supporters-group::before {
top: 30px; /* Below the title */
background: linear-gradient(to bottom, var(--lora-surface), transparent);
}
.all-supporters-group::after {
bottom: 0;
background: linear-gradient(to top, var(--lora-surface), transparent);
}
.all-supporters-group .supporters-group-title {
margin-bottom: var(--space-2);
}
.supporters-all-list {
display: flex;
flex-wrap: wrap;
align-items: baseline;
line-height: 2.2;
max-height: 550px;
overflow-y: auto;
padding: var(--space-2) 0 40px 0; /* Extra padding at bottom for final visibility */
color: var(--text-color);
scroll-behavior: auto; /* Ensure manual scroll is immediate */
}
/* Subtle scrollbar for credits look */
.supporters-all-list::-webkit-scrollbar {
width: 4px;
}
.supporters-all-list::-webkit-scrollbar-track {
background: transparent;
}
.supporters-all-list::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
.supporters-all-list:hover::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
}
.supporter-name-item {
font-size: 0.95em;
color: var(--text-color);
opacity: 0.85;
transition: all 0.2s ease;
white-space: nowrap;
cursor: default;
}
.supporter-name-item:hover {
opacity: 1;
color: var(--lora-accent);
}
.supporter-separator {
margin: 0 10px;
color: var(--text-color);
opacity: 0.25;
font-weight: 300;
user-select: none;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.support-container {
flex-direction: column;
}
.support-left {
flex: 1;
}
.support-right {
border-left: none;
border-top: 1px solid var(--lora-border);
padding-left: 0;
padding-top: var(--space-3);
}
.supporters-all-list {
max-height: 200px;
}
.supporters-special-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -250,12 +250,11 @@
.changelog-content { .changelog-content {
max-height: 550px; max-height: 550px;
overflow-y: auto; overflow-y: auto;
padding-left: var(--space-3);
} }
.changelog-item { .changelog-item {
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
padding-bottom: var(--space-2); padding: var(--space-2);
border-bottom: 1px solid var(--lora-border); border-bottom: 1px solid var(--lora-border);
} }
@@ -303,7 +302,6 @@
.changelog-item.latest { .changelog-item.latest {
background-color: rgba(66, 153, 225, 0.05); background-color: rgba(66, 153, 225, 0.05);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
padding: var(--space-2);
border: 1px solid rgba(66, 153, 225, 0.2); border: 1px solid rgba(66, 153, 225, 0.2);
} }

View File

@@ -573,3 +573,171 @@
.sidebar-tree-container::-webkit-scrollbar-thumb:hover { .sidebar-tree-container::-webkit-scrollbar-thumb:hover {
background: var(--text-muted); background: var(--text-muted);
} }
/* ===== Drag and Drop - Create Folder Zone ===== */
/* Empty state drag hint */
.sidebar-empty-hint {
margin-top: 12px;
font-size: 0.8em;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
border-radius: var(--border-radius-xs);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
border: 1px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
}
.sidebar-empty-hint i {
font-size: 0.9em;
opacity: 0.8;
margin: 0;
display: inline;
}
/* Create folder drop zone */
.sidebar-create-folder-zone {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
padding: 16px;
border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4);
border-radius: var(--border-radius-xs);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08);
opacity: 0;
transform: translateY(10px);
transition: all 0.2s ease;
pointer-events: none;
z-index: 10;
}
.sidebar-create-folder-zone.active {
opacity: 1;
transform: translateY(0);
}
.sidebar-create-folder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--lora-accent);
font-size: 0.85em;
text-align: center;
}
.sidebar-create-folder-content i {
font-size: 1.5em;
opacity: 0.8;
}
/* Create folder input container */
.sidebar-create-folder-input-container {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
padding: 12px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
z-index: 20;
animation: slideUp 0.2s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sidebar-create-folder-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-create-folder-input-wrapper > i {
color: var(--lora-accent);
font-size: 1em;
}
.sidebar-create-folder-input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.85em;
outline: none;
transition: all 0.2s ease;
}
.sidebar-create-folder-input:focus {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
}
.sidebar-create-folder-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
background: transparent;
color: var(--text-muted);
}
.sidebar-create-folder-btn:hover {
background: var(--lora-surface);
color: var(--text-color);
}
.sidebar-create-folder-confirm:hover {
background: oklch(from var(--success-color) l c h / 0.15);
color: var(--success-color);
}
.sidebar-create-folder-cancel:hover {
background: oklch(from var(--error-color) l c h / 0.15);
color: var(--error-color);
}
.sidebar-create-folder-hint {
margin-top: 6px;
font-size: 0.75em;
color: var(--text-muted);
text-align: center;
opacity: 0.8;
}
/* Dragging state for sidebar */
.folder-sidebar.dragging-active {
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5);
box-shadow: 0 0 0 3px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1),
0 2px 8px rgba(0, 0, 0, 0.08);
}
.folder-sidebar.dragging-active .sidebar-tree-container {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02);
}
/* Tree container positioning for create folder elements */
.sidebar-tree-container {
position: relative;
}

View File

@@ -196,6 +196,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
max-height: 400px;
overflow-y: auto;
padding-right: 4px;
} }
.model-item { .model-item {

View File

@@ -86,6 +86,7 @@ export function getApiEndpoints(modelType) {
// Preview management // Preview management
replacePreview: `/api/lm/${modelType}/replace-preview`, replacePreview: `/api/lm/${modelType}/replace-preview`,
setPreviewFromUrl: `/api/lm/${modelType}/set-preview-from-url`,
// Query operations // Query operations
scan: `/api/lm/${modelType}/scan`, scan: `/api/lm/${modelType}/scan`,

View File

@@ -307,6 +307,56 @@ export class BaseModelApiClient {
} }
} }
/**
* Set a preview from a remote URL (e.g., CivitAI)
* @param {string} filePath - Path to the model file
* @param {string} imageUrl - Remote image URL
* @param {number} nsfwLevel - NSFW level for the preview
*/
async setPreviewFromUrl(filePath, imageUrl, nsfwLevel = 0) {
try {
state.loadingManager.showSimpleLoading('Setting preview from URL...');
const response = await fetch(this.apiConfig.endpoints.setPreviewFromUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_path: filePath,
image_url: imageUrl,
nsfw_level: nsfwLevel
})
});
if (!response.ok) {
throw new Error('Failed to set preview from URL');
}
const data = await response.json();
const pageState = this.getPageState();
const timestamp = Date.now();
if (pageState.previewVersions) {
pageState.previewVersions.set(filePath, timestamp);
const storageKey = `${this.modelType}_preview_versions`;
saveMapToStorage(storageKey, pageState.previewVersions);
}
const updateData = {
preview_url: data.preview_url,
preview_nsfw_level: data.preview_nsfw_level
};
state.virtualScroller.updateSingleItem(filePath, updateData);
showToast('toast.api.previewUpdated', {}, 'success');
} catch (error) {
console.error('Error setting preview from URL:', error);
showToast('toast.api.previewUploadFailed', {}, 'error');
} finally {
state.loadingManager.hide();
}
}
async saveModelMetadata(filePath, data) { async saveModelMetadata(filePath, data) {
try { try {
state.loadingManager.showSimpleLoading('Saving metadata...'); state.loadingManager.showSimpleLoading('Saving metadata...');

View File

@@ -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 * Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/ */

View File

@@ -117,7 +117,10 @@ export class BulkContextMenu extends BaseContextMenu {
countSkipStatus(skipState) { countSkipStatus(skipState) {
let count = 0; let count = 0;
for (const filePath of state.selectedModels) { 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) { if (card) {
const isSkipped = card.dataset.skip_metadata_refresh === 'true'; const isSkipped = card.dataset.skip_metadata_refresh === 'true';
if (isSkipped === skipState) { if (isSkipped === skipState) {

View File

@@ -5,6 +5,7 @@ import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.js'; import { initPageState } from '../state/index.js';
import { getStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem } from '../utils/storageHelpers.js';
import { updateElementAttribute } from '../utils/i18nHelpers.js'; import { updateElementAttribute } from '../utils/i18nHelpers.js';
import { renderSupporters } from '../services/supportersService.js';
/** /**
* Header.js - Manages the application header behavior across different pages * Header.js - Manages the application header behavior across different pages
@@ -85,9 +86,15 @@ export class HeaderManager {
// Handle support toggle // Handle support toggle
const supportToggle = document.getElementById('supportToggleBtn'); const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) { if (supportToggle) {
supportToggle.addEventListener('click', () => { supportToggle.addEventListener('click', async () => {
if (window.modalManager) { if (window.modalManager) {
window.modalManager.toggleModal('supportModal'); window.modalManager.toggleModal('supportModal');
// Load supporters data when modal opens
try {
await renderSupporters();
} catch (error) {
console.error('Error loading supporters:', error);
}
} }
}); });
} }

View File

@@ -21,6 +21,7 @@ class RecipeCard {
createCardElement() { createCardElement() {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'model-card'; card.className = 'model-card';
card.draggable = true;
card.dataset.filepath = this.recipe.file_path; card.dataset.filepath = this.recipe.file_path;
card.dataset.title = this.recipe.title; card.dataset.title = this.recipe.title;
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0; card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
@@ -200,8 +201,9 @@ class RecipeCard {
this.recipe.favorite = isFavorite; this.recipe.favorite = isFavorite;
// Re-find star icon in case of re-render during fault // Re-find star icon in case of re-render during fault
const filePathForXpath = this.recipe.file_path.replace(/"/g, '&quot;');
const currentCard = card.ownerDocument.evaluate( const currentCard = card.ownerDocument.evaluate(
`.//*[@data-filepath="${this.recipe.file_path}"]`, `.//*[@data-filepath="${filePathForXpath}"]`,
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue || card; ).singleNodeValue || card;

View File

@@ -7,6 +7,7 @@ import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { escapeHtml, escapeAttribute } from './shared/utils.js';
export class SidebarManager { export class SidebarManager {
constructor() { constructor() {
@@ -29,11 +30,14 @@ export class SidebarManager {
this.draggedRootPath = null; this.draggedRootPath = null;
this.draggedFromBulk = false; this.draggedFromBulk = false;
this.dragHandlersInitialized = false; this.dragHandlersInitialized = false;
this.sidebarDragHandlersInitialized = false;
this.folderTreeElement = null; this.folderTreeElement = null;
this.currentDropTarget = null; this.currentDropTarget = null;
this.lastPageControls = null; this.lastPageControls = null;
this.isDisabledBySetting = false; this.isDisabledBySetting = false;
this.initializationPromise = null; this.initializationPromise = null;
this.isCreatingFolder = false;
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
// Bind methods // Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this); this.handleTreeClick = this.handleTreeClick.bind(this);
@@ -56,6 +60,12 @@ export class SidebarManager {
this.handleFolderDragOver = this.handleFolderDragOver.bind(this); this.handleFolderDragOver = this.handleFolderDragOver.bind(this);
this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this); this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this);
this.handleFolderDrop = this.handleFolderDrop.bind(this); this.handleFolderDrop = this.handleFolderDrop.bind(this);
this.handleSidebarDragEnter = this.handleSidebarDragEnter.bind(this);
this.handleSidebarDragOver = this.handleSidebarDragOver.bind(this);
this.handleSidebarDragLeave = this.handleSidebarDragLeave.bind(this);
this.handleSidebarDrop = this.handleSidebarDrop.bind(this);
this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
} }
setHostPageControls(pageControls) { setHostPageControls(pageControls) {
@@ -118,19 +128,18 @@ export class SidebarManager {
this.removeEventHandlers(); this.removeEventHandlers();
this.clearAllDropHighlights(); this.clearAllDropHighlights();
if (this.dragHandlersInitialized) {
document.removeEventListener('dragstart', this.handleCardDragStart);
document.removeEventListener('dragend', this.handleCardDragEnd);
this.dragHandlersInitialized = false;
}
if (this.folderTreeElement) {
this.folderTreeElement.removeEventListener('dragenter', this.handleFolderDragEnter);
this.folderTreeElement.removeEventListener('dragover', this.handleFolderDragOver);
this.folderTreeElement.removeEventListener('dragleave', this.handleFolderDragLeave);
this.folderTreeElement.removeEventListener('drop', this.handleFolderDrop);
this.folderTreeElement = null;
}
this.resetDragState(); this.resetDragState();
this.hideCreateFolderInput();
// Cleanup sidebar drag handlers
const sidebar = document.getElementById('folderSidebar');
if (sidebar && this.sidebarDragHandlersInitialized) {
sidebar.removeEventListener('dragenter', this.handleSidebarDragEnter);
sidebar.removeEventListener('dragover', this.handleSidebarDragOver);
sidebar.removeEventListener('dragleave', this.handleSidebarDragLeave);
sidebar.removeEventListener('drop', this.handleSidebarDrop);
this.sidebarDragHandlersInitialized = false;
}
// Reset state // Reset state
this.pageControls = null; this.pageControls = null;
@@ -233,6 +242,16 @@ export class SidebarManager {
this.folderTreeElement = folderTree; this.folderTreeElement = folderTree;
} }
// Add sidebar-level drag handlers for creating new folders
const sidebar = document.getElementById('folderSidebar');
if (sidebar && !this.sidebarDragHandlersInitialized) {
sidebar.addEventListener('dragenter', this.handleSidebarDragEnter);
sidebar.addEventListener('dragover', this.handleSidebarDragOver);
sidebar.addEventListener('dragleave', this.handleSidebarDragLeave);
sidebar.addEventListener('drop', this.handleSidebarDrop);
this.sidebarDragHandlersInitialized = true;
}
} }
handleCardDragStart(event) { handleCardDragStart(event) {
@@ -271,6 +290,12 @@ export class SidebarManager {
} }
card.classList.add('dragging'); card.classList.add('dragging');
// Add dragging state to sidebar for visual feedback
const sidebar = document.getElementById('folderSidebar');
if (sidebar) {
sidebar.classList.add('dragging-active');
}
} }
handleCardDragEnd(event) { handleCardDragEnd(event) {
@@ -278,6 +303,13 @@ export class SidebarManager {
if (card) { if (card) {
card.classList.remove('dragging'); card.classList.remove('dragging');
} }
// Remove dragging state from sidebar
const sidebar = document.getElementById('folderSidebar');
if (sidebar) {
sidebar.classList.remove('dragging-active');
}
this.clearAllDropHighlights(); this.clearAllDropHighlights();
this.resetDragState(); this.resetDragState();
} }
@@ -417,7 +449,12 @@ export class SidebarManager {
} }
async performDragMove(targetRelativePath) { async performDragMove(targetRelativePath) {
console.log('[SidebarManager] performDragMove called with targetRelativePath:', targetRelativePath);
console.log('[SidebarManager] draggedFilePaths:', this.draggedFilePaths);
console.log('[SidebarManager] draggedRootPath:', this.draggedRootPath);
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) { if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) {
console.log('[SidebarManager] performDragMove returning false - no draggedFilePaths');
return false; return false;
} }
@@ -428,12 +465,15 @@ export class SidebarManager {
} }
if (this.apiClient?.apiConfig?.config?.supportsMove === false) { if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
console.log('[SidebarManager] performDragMove returning false - supportsMove is false');
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error'); showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
return false; return false;
} }
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : ''; const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
console.log('[SidebarManager] rootPath:', rootPath);
if (!rootPath) { if (!rootPath) {
console.log('[SidebarManager] performDragMove returning false - no rootPath');
showToast( showToast(
'toast.models.moveFailed', 'toast.models.moveFailed',
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') }, { message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
@@ -446,15 +486,19 @@ export class SidebarManager {
const useBulkMove = this.draggedFromBulk || this.draggedFilePaths.length > 1; const useBulkMove = this.draggedFromBulk || this.draggedFilePaths.length > 1;
try { try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
if (useBulkMove) { if (useBulkMove) {
await this.apiClient.moveBulkModels(this.draggedFilePaths, destination); await this.apiClient.moveBulkModels(this.draggedFilePaths, destination);
} else { } else {
await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination); await this.apiClient.moveSingleModel(this.draggedFilePaths[0], destination);
} }
console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') { if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
console.log('[SidebarManager] calling resetAndReload');
await this.pageControls.resetAndReload(true); await this.pageControls.resetAndReload(true);
} else { } else {
console.log('[SidebarManager] calling refresh');
await this.refresh(); await this.refresh();
} }
@@ -462,10 +506,12 @@ export class SidebarManager {
bulkManager.toggleBulkMode(); bulkManager.toggleBulkMode();
} }
console.log('[SidebarManager] performDragMove returning true');
return true; return true;
} catch (error) { } catch (error) {
console.error('Error moving model(s) via drag-and-drop:', error); console.error('[SidebarManager] Error moving model(s) via drag-and-drop:', error);
showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error'); showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error');
console.log('[SidebarManager] performDragMove returning false due to error');
return false; return false;
} }
} }
@@ -476,6 +522,365 @@ export class SidebarManager {
this.draggedFromBulk = false; this.draggedFromBulk = false;
} }
// Version of performDragMove that accepts state as parameters (for create folder submit)
async performDragMoveWithState(targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk) {
console.log('[SidebarManager] performDragMoveWithState called with:', { targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk });
if (!draggedFilePaths || draggedFilePaths.length === 0) {
console.log('[SidebarManager] performDragMoveWithState returning false - no draggedFilePaths');
return false;
}
if (!this.apiClient) {
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
}
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
console.log('[SidebarManager] performDragMoveWithState returning false - supportsMove is false');
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
return false;
}
const rootPath = draggedRootPath ? draggedRootPath.replace(/\\/g, '/') : '';
console.log('[SidebarManager] rootPath:', rootPath);
if (!rootPath) {
console.log('[SidebarManager] performDragMoveWithState returning false - no rootPath');
showToast(
'toast.models.moveFailed',
{ message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') },
'error'
);
return false;
}
const destination = this.combineRootAndRelativePath(rootPath, targetRelativePath);
const useBulkMove = draggedFromBulk || draggedFilePaths.length > 1;
try {
console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove);
if (useBulkMove) {
await this.apiClient.moveBulkModels(draggedFilePaths, destination);
} else {
await this.apiClient.moveSingleModel(draggedFilePaths[0], destination);
}
console.log('[SidebarManager] apiClient.move successful');
if (this.pageControls && typeof this.pageControls.resetAndReload === 'function') {
console.log('[SidebarManager] calling resetAndReload');
await this.pageControls.resetAndReload(true);
} else {
console.log('[SidebarManager] calling refresh');
await this.refresh();
}
if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') {
bulkManager.toggleBulkMode();
}
console.log('[SidebarManager] performDragMoveWithState returning true');
return true;
} catch (error) {
console.error('[SidebarManager] Error moving model(s) via drag-and-drop:', error);
showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error');
console.log('[SidebarManager] performDragMoveWithState returning false due to error');
return false;
}
}
// ===== Sidebar-level drag handlers for creating new folders =====
handleSidebarDragEnter(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const sidebar = document.getElementById('folderSidebar');
if (!sidebar) return;
// Only show create folder zone if not hovering over an existing folder
const folderElement = this.getFolderElementFromEvent(event);
if (folderElement) {
this.hideCreateFolderZone();
return;
}
// Check if drag is within the sidebar tree container area
const treeContainer = document.querySelector('.sidebar-tree-container');
if (treeContainer && treeContainer.contains(event.target)) {
event.preventDefault();
this.showCreateFolderZone();
}
}
handleSidebarDragOver(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (folderElement) {
this.hideCreateFolderZone();
return;
}
const treeContainer = document.querySelector('.sidebar-tree-container');
if (treeContainer && treeContainer.contains(event.target)) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
}
}
handleSidebarDragLeave(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const sidebar = document.getElementById('folderSidebar');
if (!sidebar) return;
const relatedTarget = event.relatedTarget instanceof Element ? event.relatedTarget : null;
// Only hide if leaving the sidebar entirely
if (!relatedTarget || !sidebar.contains(relatedTarget)) {
this.hideCreateFolderZone();
}
}
async handleSidebarDrop(event) {
if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return;
const folderElement = this.getFolderElementFromEvent(event);
if (folderElement) {
// Let the folder drop handler take over
return;
}
const treeContainer = document.querySelector('.sidebar-tree-container');
if (!treeContainer || !treeContainer.contains(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
// Show create folder input
this.showCreateFolderInput();
}
showCreateFolderZone() {
if (this.isCreatingFolder) return;
const treeContainer = document.querySelector('.sidebar-tree-container');
if (!treeContainer) return;
let zone = document.getElementById('sidebarCreateFolderZone');
if (!zone) {
zone = document.createElement('div');
zone.id = 'sidebarCreateFolderZone';
zone.className = 'sidebar-create-folder-zone';
zone.innerHTML = `
<div class="sidebar-create-folder-content">
<i class="fas fa-plus-circle"></i>
<span>${translate('sidebar.dragDrop.createFolderHint', {}, 'Release to create new folder')}</span>
</div>
`;
treeContainer.appendChild(zone);
}
zone.classList.add('active');
}
hideCreateFolderZone() {
const zone = document.getElementById('sidebarCreateFolderZone');
if (zone) {
zone.classList.remove('active');
}
}
showCreateFolderInput() {
console.log('[SidebarManager] showCreateFolderInput called');
this.isCreatingFolder = true;
// 立即保存拖拽状态防止后续事件如blur清空状态
this._pendingDragState = {
filePaths: this.draggedFilePaths ? [...this.draggedFilePaths] : null,
rootPath: this.draggedRootPath,
fromBulk: this.draggedFromBulk
};
console.log('[SidebarManager] saved pending drag state:', this._pendingDragState);
this.hideCreateFolderZone();
const treeContainer = document.querySelector('.sidebar-tree-container');
if (!treeContainer) return;
// Remove existing input if any
this.hideCreateFolderInput();
const inputContainer = document.createElement('div');
inputContainer.id = 'sidebarCreateFolderInput';
inputContainer.className = 'sidebar-create-folder-input-container';
inputContainer.innerHTML = `
<div class="sidebar-create-folder-input-wrapper">
<i class="fas fa-folder-plus"></i>
<input type="text"
class="sidebar-create-folder-input"
placeholder="${translate('sidebar.dragDrop.newFolderName', {}, 'New folder name')}"
autofocus />
<button class="sidebar-create-folder-btn sidebar-create-folder-confirm" title="${translate('common.confirm', {}, 'Confirm')}">
<i class="fas fa-check"></i>
</button>
<button class="sidebar-create-folder-btn sidebar-create-folder-cancel" title="${translate('common.cancel', {}, 'Cancel')}">
<i class="fas fa-times"></i>
</button>
</div>
<div class="sidebar-create-folder-hint">
${translate('sidebar.dragDrop.folderNameHint', {}, 'Press Enter to confirm, Escape to cancel')}
</div>
`;
treeContainer.appendChild(inputContainer);
// Focus input
const input = inputContainer.querySelector('.sidebar-create-folder-input');
if (input) {
input.focus();
}
// Bind events
const confirmBtn = inputContainer.querySelector('.sidebar-create-folder-confirm');
const cancelBtn = inputContainer.querySelector('.sidebar-create-folder-cancel');
// Flag to prevent blur from canceling when clicking buttons
let isButtonClick = false;
confirmBtn?.addEventListener('mousedown', () => {
isButtonClick = true;
console.log('[SidebarManager] confirmBtn mousedown - isButtonClick set to true');
});
cancelBtn?.addEventListener('mousedown', () => {
isButtonClick = true;
console.log('[SidebarManager] cancelBtn mousedown - isButtonClick set to true');
});
confirmBtn?.addEventListener('click', (e) => {
console.log('[SidebarManager] confirmBtn click event triggered');
this.handleCreateFolderSubmit();
});
cancelBtn?.addEventListener('click', () => {
console.log('[SidebarManager] cancelBtn click event triggered');
this.handleCreateFolderCancel();
});
input?.addEventListener('keydown', (e) => {
console.log('[SidebarManager] input keydown:', e.key);
if (e.key === 'Enter') {
console.log('[SidebarManager] Enter pressed, calling handleCreateFolderSubmit');
this.handleCreateFolderSubmit();
} else if (e.key === 'Escape') {
console.log('[SidebarManager] Escape pressed, calling handleCreateFolderCancel');
this.handleCreateFolderCancel();
}
});
input?.addEventListener('blur', () => {
console.log('[SidebarManager] input blur event - isButtonClick:', isButtonClick);
// Delay to allow button clicks to process first
setTimeout(() => {
console.log('[SidebarManager] blur timeout - isButtonClick:', isButtonClick, 'activeElement:', document.activeElement?.className);
if (!isButtonClick && document.activeElement !== confirmBtn && document.activeElement !== cancelBtn) {
console.log('[SidebarManager] blur timeout - calling handleCreateFolderCancel');
this.handleCreateFolderCancel();
} else {
console.log('[SidebarManager] blur timeout - NOT canceling (button click detected)');
}
isButtonClick = false;
}, 200);
});
}
hideCreateFolderInput() {
console.log('[SidebarManager] hideCreateFolderInput called');
const inputContainer = document.getElementById('sidebarCreateFolderInput');
console.log('[SidebarManager] inputContainer:', inputContainer);
if (inputContainer) {
inputContainer.remove();
console.log('[SidebarManager] inputContainer removed');
}
this.isCreatingFolder = false;
console.log('[SidebarManager] isCreatingFolder set to false');
}
async handleCreateFolderSubmit() {
console.log('[SidebarManager] handleCreateFolderSubmit called');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
console.log('[SidebarManager] input element:', input);
if (!input) {
console.log('[SidebarManager] input not found, returning');
return;
}
const folderName = input.value.trim();
console.log('[SidebarManager] folderName:', folderName);
if (!folderName) {
showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning');
return;
}
// Validate folder name (no slashes, no special chars)
if (/[\\/:*?"<>|]/.test(folderName)) {
showToast('sidebar.dragDrop.invalidFolderName', {}, 'error');
return;
}
// Build target path - use selected path as parent, or root if none selected
const parentPath = this.selectedPath || '';
const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName;
console.log('[SidebarManager] targetRelativePath:', targetRelativePath);
// 使用 showCreateFolderInput 时保存的拖拽状态
const pendingState = this._pendingDragState;
console.log('[SidebarManager] using pending drag state:', pendingState);
if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) {
console.log('[SidebarManager] no pending drag state found, cannot proceed');
showToast('sidebar.dragDrop.noDragState', {}, 'error');
this.hideCreateFolderInput();
return;
}
this.hideCreateFolderInput();
// Perform the move with saved state
console.log('[SidebarManager] calling performDragMove with pending state');
const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk);
console.log('[SidebarManager] performDragMove result:', success);
if (success) {
// Expand the parent folder to show the new folder
if (parentPath) {
this.expandedNodes.add(parentPath);
this.saveExpandedState();
}
// Refresh the tree to show the newly created folder
// restoreSelectedFolder() inside refresh() will maintain the current active folder
await this.refresh();
}
// 清理待处理的拖拽状态
this._pendingDragState = null;
this.resetDragState();
this.clearAllDropHighlights();
}
handleCreateFolderCancel() {
this.hideCreateFolderInput();
// 清理待处理的拖拽状态
this._pendingDragState = null;
this.resetDragState();
this.clearAllDropHighlights();
}
saveSelectedFolder() {
setStorageItem(`${this.pageType}_activeFolder`, this.selectedPath);
}
clearAllDropHighlights() { clearAllDropHighlights() {
const highlighted = document.querySelectorAll('.sidebar-tree-node-content.drop-target, .sidebar-node-content.drop-target'); const highlighted = document.querySelectorAll('.sidebar-tree-node-content.drop-target, .sidebar-node-content.drop-target');
highlighted.forEach((element) => element.classList.remove('drop-target')); highlighted.forEach((element) => element.classList.remove('drop-target'));
@@ -890,15 +1295,19 @@ export class SidebarManager {
const isExpanded = this.expandedNodes.has(currentPath); const isExpanded = this.expandedNodes.has(currentPath);
const isSelected = this.selectedPath === currentPath; const isSelected = this.selectedPath === currentPath;
const escapedPath = escapeAttribute(currentPath);
const escapedFolderName = escapeHtml(folderName);
const escapedTitle = escapeAttribute(folderName);
return ` return `
<div class="sidebar-tree-node" data-path="${currentPath}"> <div class="sidebar-tree-node" data-path="${escapedPath}">
<div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${currentPath}"> <div class="sidebar-tree-node-content ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
<div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}" <div class="sidebar-tree-expand-icon ${isExpanded ? 'expanded' : ''}"
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}"> style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
<i class="fas fa-chevron-right"></i> <i class="fas fa-chevron-right"></i>
</div> </div>
<i class="fas fa-folder sidebar-tree-folder-icon"></i> <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> </div>
${hasChildren ? ` ${hasChildren ? `
<div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}"> <div class="sidebar-tree-children ${isExpanded ? 'expanded' : ''}">
@@ -917,7 +1326,11 @@ export class SidebarManager {
folderTree.innerHTML = ` folderTree.innerHTML = `
<div class="sidebar-tree-placeholder"> <div class="sidebar-tree-placeholder">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open"></i>
<div>No folders found</div> <div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div>
<div class="sidebar-empty-hint">
<i class="fas fa-hand-pointer"></i>
${translate('sidebar.empty.dragHint', {}, 'Drag items here to create folders')}
</div>
</div> </div>
`; `;
} }
@@ -934,12 +1347,15 @@ export class SidebarManager {
const foldersHtml = this.foldersList.map(folder => { const foldersHtml = this.foldersList.map(folder => {
const displayName = folder === '' ? '/' : folder; const displayName = folder === '' ? '/' : folder;
const isSelected = this.selectedPath === folder; const isSelected = this.selectedPath === folder;
const escapedPath = escapeAttribute(folder);
const escapedDisplayName = escapeHtml(displayName);
const escapedTitle = escapeAttribute(displayName);
return ` return `
<div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${folder}"> <div class="sidebar-folder-item ${isSelected ? 'selected' : ''}" data-path="${escapedPath}">
<div class="sidebar-node-content" data-path="${folder}"> <div class="sidebar-node-content" data-path="${escapedPath}">
<i class="fas fa-folder sidebar-folder-icon"></i> <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>
</div> </div>
`; `;
@@ -1162,7 +1578,8 @@ export class SidebarManager {
// Add selection to current path // Add selection to current path
if (this.selectedPath !== null && this.selectedPath !== undefined) { 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) { if (selectedItem) {
selectedItem.classList.add('selected'); selectedItem.classList.add('selected');
} }
@@ -1173,7 +1590,8 @@ export class SidebarManager {
}); });
if (this.selectedPath !== null && this.selectedPath !== undefined) { 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) { if (selectedNode) {
selectedNode.classList.add('selected'); selectedNode.classList.add('selected');
this.expandPathParents(this.selectedPath); this.expandPathParents(this.selectedPath);
@@ -1247,7 +1665,7 @@ export class SidebarManager {
const breadcrumbs = [` const breadcrumbs = [`
<div class="breadcrumb-dropdown"> <div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item ${isRootSelected ? 'active' : ''}" data-path=""> <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> </span>
</div> </div>
`]; `];
@@ -1267,8 +1685,8 @@ export class SidebarManager {
</span> </span>
<div class="breadcrumb-dropdown-menu"> <div class="breadcrumb-dropdown-menu">
${nextLevelFolders.map(folder => ` ${nextLevelFolders.map(folder => `
<div class="breadcrumb-dropdown-item" data-path="${folder}"> <div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(folder)}">
${folder} ${escapeHtml(folder)}
</div>`).join('') </div>`).join('')
} }
</div> </div>
@@ -1284,12 +1702,14 @@ export class SidebarManager {
// Get siblings for this level // Get siblings for this level
const siblings = this.getSiblingFolders(parts, index); const siblings = this.getSiblingFolders(parts, index);
const escapedCurrentPath = escapeAttribute(currentPath);
const escapedPart = escapeHtml(part);
breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`); breadcrumbs.push(`<span class="sidebar-breadcrumb-separator">/</span>`);
breadcrumbs.push(` breadcrumbs.push(`
<div class="breadcrumb-dropdown"> <div class="breadcrumb-dropdown">
<span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}"> <span class="sidebar-breadcrumb-item ${isLast ? 'active' : ''}" data-path="${escapedCurrentPath}">
${part} ${escapedPart}
${siblings.length > 1 ? ` ${siblings.length > 1 ? `
<span class="breadcrumb-dropdown-indicator"> <span class="breadcrumb-dropdown-indicator">
<i class="fas fa-caret-down"></i> <i class="fas fa-caret-down"></i>
@@ -1298,11 +1718,14 @@ export class SidebarManager {
</span> </span>
${siblings.length > 1 ? ` ${siblings.length > 1 ? `
<div class="breadcrumb-dropdown-menu"> <div class="breadcrumb-dropdown-menu">
${siblings.map(folder => ` ${siblings.map(folder => {
<div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}" const siblingPath = parts.slice(0, index).concat(folder).join('/');
data-path="${currentPath.replace(part, folder)}"> return `
${folder} <div class="breadcrumb-dropdown-item ${folder === part ? 'active' : ''}"
</div>`).join('') data-path="${escapeAttribute(siblingPath)}">
${escapeHtml(folder)}
</div>`;
}).join('')
} }
</div> </div>
` : ''} ` : ''}
@@ -1324,8 +1747,8 @@ export class SidebarManager {
</span> </span>
<div class="breadcrumb-dropdown-menu"> <div class="breadcrumb-dropdown-menu">
${childFolders.map(folder => ` ${childFolders.map(folder => `
<div class="breadcrumb-dropdown-item" data-path="${currentPath}/${folder}"> <div class="breadcrumb-dropdown-item" data-path="${escapeAttribute(currentPath + '/' + folder)}">
${folder} ${escapeHtml(folder)}
</div>`).join('') </div>`).join('')
} }
</div> </div>

View File

@@ -846,8 +846,14 @@ function setupLoraSpecificFields(filePath) {
const currentPath = resolveFilePath(); const currentPath = resolveFilePath();
if (!currentPath) return; if (!currentPath) return;
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) || const escapedCurrentPath = window.CSS && typeof window.CSS.escape === 'function'
document.querySelector(`.model-card[data-filepath="${filePath}"]`); ? 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); const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
if (key === 'strength_range') { if (key === 'strength_range') {

View File

@@ -49,7 +49,10 @@ function formatPresetKey(key) {
*/ */
window.removePreset = async function(key) { window.removePreset = async function(key) {
const filePath = document.querySelector('#modelModal .modal-content .file-path').dataset.filepath; 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); const currentPresets = parsePresets(loraCard.dataset.usage_tips);
delete currentPresets[key]; delete currentPresets[key];

View File

@@ -26,8 +26,7 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
</button> </button>
` : ''} ` : ''}
${mediaControlsHtml} ${mediaControlsHtml}
<video controls autoplay muted loop crossorigin="anonymous" <video controls autoplay muted loop
referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}" data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}" data-remote-src="${remoteUrl}"
data-nsfw-level="${nsfwLevel}" data-nsfw-level="${nsfwLevel}"

View File

@@ -529,15 +529,16 @@ function initSetPreviewHandlers(container) {
const file = new File([blob], 'preview.jpg', { type: blob.type }); const file = new File([blob], 'preview.jpg', { type: blob.type });
// Use the existing baseModelApi uploadPreview method with nsfw level // Use the existing baseModelApi uploadPreview method with nsfw level
await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); await apiClient.uploadPreview(modelFilePath, file, nsfwLevel);
} else { } else {
// We need to download the remote file first // Remote file - send URL to backend to download (avoids CORS issues)
const response = await fetch(mediaElement.src); const imageUrl = mediaElement.src || mediaElement.dataset.remoteSrc;
const blob = await response.blob(); if (!imageUrl) {
const file = new File([blob], 'preview.jpg', { type: blob.type }); throw new Error('No image URL available');
}
// Use the existing baseModelApi uploadPreview method with nsfw level // Use the new setPreviewFromUrl method
await apiClient.uploadPreview(modelFilePath, file, modelType, nsfwLevel); await apiClient.setPreviewFromUrl(modelFilePath, imageUrl, nsfwLevel);
} }
} catch (error) { } catch (error) {
console.error('Error setting preview:', error); console.error('Error setting preview:', error);

View File

@@ -2,6 +2,7 @@
* MetadataPanel.js * MetadataPanel.js
* Generates metadata panels for showcase media items * Generates metadata panels for showcase media items
*/ */
import { escapeHtml } from '../utils.js';
/** /**
* Generate metadata panel HTML * Generate metadata panel HTML
@@ -49,6 +50,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
} }
if (prompt) { if (prompt) {
prompt = escapeHtml(prompt);
content += ` content += `
<div class="metadata-row prompt-row"> <div class="metadata-row prompt-row">
<span class="metadata-label">Prompt:</span> <span class="metadata-label">Prompt:</span>
@@ -64,6 +66,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
} }
if (negativePrompt) { if (negativePrompt) {
negativePrompt = escapeHtml(negativePrompt);
content += ` content += `
<div class="metadata-row prompt-row"> <div class="metadata-row prompt-row">
<span class="metadata-label">Negative Prompt:</span> <span class="metadata-label">Negative Prompt:</span>

View File

@@ -16,6 +16,7 @@ import {
} from './MediaUtils.js'; } from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
export const showcaseListenerMetrics = { export const showcaseListenerMetrics = {
wheelListeners: 0, wheelListeners: 0,
@@ -61,8 +62,14 @@ export async function loadExampleImages(images, modelHash) {
// Re-initialize the showcase event listeners // Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel'); const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) { if (carousel) {
initShowcaseContent(carousel); // Always bind scroll-indicator click events (even when collapsed)
bindScrollIndicatorEvents(carousel);
// Only initialize full showcase content when expanded
if (!carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
} }
// Initialize the example import functionality // Initialize the example import functionality
@@ -152,10 +159,18 @@ function renderMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files // Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles); let localFile = findLocalFile(img, index, exampleFiles);
const remoteUrl = img.url || ''; // Get original remote URL
const localUrl = localFile ? localFile.path : ''; const originalRemoteUrl = img.url || '';
// Determine media type for optimization
const isVideo = localFile ? localFile.is_video : const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm'); originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
// Optimize CivitAI URLs for showcase display (full quality)
const remoteUrl = getShowcaseUrl(originalRemoteUrl, mediaType);
const localUrl = localFile ? localFile.path : '';
// Calculate appropriate aspect ratio // Calculate appropriate aspect ratio
const aspectRatio = (img.height / img.width) * 100; const aspectRatio = (img.height / img.width) * 100;
@@ -576,6 +591,41 @@ export function toggleShowcase(element) {
} }
} }
/**
* Bind scroll-indicator click events (works even when carousel is collapsed)
* @param {HTMLElement} carousel - The carousel element
*/
function bindScrollIndicatorEvents(carousel) {
if (!carousel) return;
const scrollIndicator = carousel.previousElementSibling;
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
// Remove previous listeners to avoid duplicates
scrollIndicator.onclick = null;
scrollIndicator.removeEventListener('click', scrollIndicator._leftClickHandler);
scrollIndicator.removeEventListener('mousedown', scrollIndicator._middleClickHandler);
// Handler for left-click (button 0) - uses 'click' event
scrollIndicator._leftClickHandler = (event) => {
if (event.button === 0) {
event.preventDefault();
toggleShowcase(scrollIndicator);
}
};
// Handler for middle-click (button 1) - uses 'mousedown' event
scrollIndicator._middleClickHandler = (event) => {
if (event.button === 1) {
event.preventDefault();
toggleShowcase(scrollIndicator);
}
};
scrollIndicator.addEventListener('click', scrollIndicator._leftClickHandler);
scrollIndicator.addEventListener('mousedown', scrollIndicator._middleClickHandler);
}
}
/** /**
* Initialize all showcase content interactions * Initialize all showcase content interactions
* @param {HTMLElement} carousel - The carousel element * @param {HTMLElement} carousel - The carousel element
@@ -589,15 +639,8 @@ export function initShowcaseContent(carousel) {
initMediaControlHandlers(carousel); initMediaControlHandlers(carousel);
positionAllMediaControls(carousel); positionAllMediaControls(carousel);
// Bind scroll-indicator click to toggleShowcase // Bind scroll-indicator click events
const scrollIndicator = carousel.previousElementSibling; bindScrollIndicatorEvents(carousel);
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
// Remove previous click listeners to avoid duplicates
scrollIndicator.onclick = null;
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
}
// Add window resize handler // Add window resize handler
const resizeHandler = () => positionAllMediaControls(carousel); const resizeHandler = () => positionAllMediaControls(carousel);

View File

@@ -0,0 +1,807 @@
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();
}
/**
* 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();

View File

@@ -568,7 +568,8 @@ export class BulkManager {
} }
deselectItem(filepath) { 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) { if (card) {
card.classList.remove('selected'); card.classList.remove('selected');
} }
@@ -632,7 +633,8 @@ export class BulkManager {
for (const filepath of state.selectedModels) { for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath); const metadata = metadataCache.get(filepath);
if (metadata) { 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) { if (card) {
this.updateMetadataCacheFromCard(filepath, card); this.updateMetadataCacheFromCard(filepath, card);
} }

View File

@@ -8,6 +8,22 @@ export class LoadingManager {
return LoadingManager.instance; return LoadingManager.instance;
} }
// Delay DOM creation until first use to ensure i18n is ready
this._initialized = false;
this.overlay = null;
this.loadingContent = null;
this.progressBar = null;
this.statusText = null;
this.cancelButton = null;
this.onCancelCallback = null;
this.detailsContainer = null;
LoadingManager.instance = this;
}
_ensureInitialized() {
if (this._initialized) return;
this.overlay = document.getElementById('loading-overlay'); this.overlay = document.getElementById('loading-overlay');
if (!this.overlay) { if (!this.overlay) {
@@ -53,7 +69,6 @@ export class LoadingManager {
this.loadingContent.appendChild(this.cancelButton); this.loadingContent.appendChild(this.cancelButton);
} }
this.onCancelCallback = null;
this.cancelButton.onclick = () => { this.cancelButton.onclick = () => {
if (this.onCancelCallback) { if (this.onCancelCallback) {
this.onCancelCallback(); this.onCancelCallback();
@@ -62,12 +77,11 @@ export class LoadingManager {
} }
}; };
this.detailsContainer = null; // Will be created when needed this._initialized = true;
LoadingManager.instance = this;
} }
show(message = 'Loading...', progress = 0) { show(message = 'Loading...', progress = 0) {
this._ensureInitialized();
this.overlay.style.display = 'flex'; this.overlay.style.display = 'flex';
this.setProgress(progress); this.setProgress(progress);
this.setStatus(message); this.setStatus(message);
@@ -77,21 +91,25 @@ export class LoadingManager {
} }
hide() { hide() {
if (!this._initialized) return;
this.overlay.style.display = 'none'; this.overlay.style.display = 'none';
this.reset(); this.reset();
this.removeDetailsContainer(); this.removeDetailsContainer();
} }
setProgress(percent) { setProgress(percent) {
if (!this._initialized) return;
this.progressBar.style.width = `${percent}%`; this.progressBar.style.width = `${percent}%`;
this.progressBar.setAttribute('aria-valuenow', percent); this.progressBar.setAttribute('aria-valuenow', percent);
} }
setStatus(message) { setStatus(message) {
if (!this._initialized) return;
this.statusText.textContent = message; this.statusText.textContent = message;
} }
reset() { reset() {
if (!this._initialized) return;
this.setProgress(0); this.setProgress(0);
this.setStatus(''); this.setStatus('');
this.removeDetailsContainer(); this.removeDetailsContainer();
@@ -100,6 +118,7 @@ export class LoadingManager {
} }
showCancelButton(onCancel) { showCancelButton(onCancel) {
this._ensureInitialized();
if (this.cancelButton) { if (this.cancelButton) {
this.onCancelCallback = onCancel; this.onCancelCallback = onCancel;
this.cancelButton.style.display = 'flex'; this.cancelButton.style.display = 'flex';
@@ -109,6 +128,7 @@ export class LoadingManager {
} }
hideCancelButton() { hideCancelButton() {
if (!this._initialized) return;
if (this.cancelButton) { if (this.cancelButton) {
this.cancelButton.style.display = 'none'; this.cancelButton.style.display = 'none';
this.onCancelCallback = null; this.onCancelCallback = null;
@@ -117,6 +137,7 @@ export class LoadingManager {
// Create a details container for enhanced progress display // Create a details container for enhanced progress display
createDetailsContainer() { createDetailsContainer() {
this._ensureInitialized();
// Remove existing container if any // Remove existing container if any
this.removeDetailsContainer(); this.removeDetailsContainer();
@@ -332,12 +353,14 @@ export class LoadingManager {
} }
showSimpleLoading(message = 'Loading...') { showSimpleLoading(message = 'Loading...') {
this._ensureInitialized();
this.overlay.style.display = 'flex'; this.overlay.style.display = 'flex';
this.progressBar.style.display = 'none'; this.progressBar.style.display = 'none';
this.setStatus(message); this.setStatus(message);
} }
restoreProgressBar() { restoreProgressBar() {
if (!this._initialized) return;
this.progressBar.style.display = 'block'; this.progressBar.style.display = 'block';
} }
} }

View File

@@ -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 // Add recipeModal registration
const recipeModal = document.getElementById('recipeModal'); const recipeModal = document.getElementById('recipeModal');
if (recipeModal) { if (recipeModal) {

View File

@@ -88,6 +88,11 @@ class MoveManager {
folderPathInput.value = ''; folderPathInput.value = '';
} }
// Reset folder tree selection
if (this.folderTreeManager) {
this.folderTreeManager.clearSelection();
}
try { try {
// Fetch model roots // Fetch model roots
const modelRootSelect = document.getElementById('moveModelRoot'); const modelRootSelect = document.getElementById('moveModelRoot');
@@ -286,6 +291,9 @@ class MoveManager {
if (recursive) { if (recursive) {
// Visible if it's in activeFolder or any subfolder // Visible if it's in activeFolder or any subfolder
// Special case for root: if activeFolder is empty, everything is visible in recursive mode
if (normalizedActive === '') return true;
return normalizedRelative === normalizedActive || return normalizedRelative === normalizedActive ||
normalizedRelative.startsWith(normalizedActive + '/'); normalizedRelative.startsWith(normalizedActive + '/');
} else { } else {
@@ -305,7 +313,7 @@ class MoveManager {
} }
// Get selected folder path from folder tree manager // Get selected folder path from folder tree manager
const targetFolder = this.folderTreeManager.getSelectedPath(); const targetFolder = this.useDefaultPath ? '' : this.folderTreeManager.getSelectedPath();
let targetPath = selectedRoot; let targetPath = selectedRoot;
if (targetFolder) { if (targetFolder) {
@@ -315,81 +323,31 @@ class MoveManager {
try { try {
if (this.bulkFilePaths) { if (this.bulkFilePaths) {
// Bulk move mode // Bulk move mode
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath); await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
// Update virtual scroller visibility/metadata // Deselect moving items
const pageState = getCurrentPageState(); this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
if (state.virtualScroller) {
results.forEach(result => {
if (result.success) {
// Deselect moving item
bulkManager.deselectItem(result.original_file_path);
const newRelativeFolder = this._getRelativeFolder(result.new_file_path);
const isVisible = this._isModelVisible(newRelativeFolder, pageState);
if (!isVisible) {
state.virtualScroller.removeItemByFilePath(result.original_file_path);
} else {
const newFileNameWithExt = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
const baseFileName = newFileNameWithExt.substring(0, newFileNameWithExt.lastIndexOf('.'));
const updateData = {
file_path: result.new_file_path,
file_name: baseFileName,
folder: newRelativeFolder
};
// Only update sub_type if it's present in the cache_entry
if (result.cache_entry && result.cache_entry.sub_type) {
updateData.sub_type = result.cache_entry.sub_type;
}
state.virtualScroller.updateSingleItem(result.original_file_path, updateData);
}
}
});
}
} else { } else {
// Single move mode // Single move mode
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath); await apiClient.moveSingleModel(this.currentFilePath, targetPath, this.useDefaultPath);
const pageState = getCurrentPageState(); // Deselect moving item
if (result && result.new_file_path && state.virtualScroller) { bulkManager.deselectItem(this.currentFilePath);
// Deselect moving item
bulkManager.deselectItem(this.currentFilePath);
const newRelativeFolder = this._getRelativeFolder(result.new_file_path);
const isVisible = this._isModelVisible(newRelativeFolder, pageState);
if (!isVisible) {
state.virtualScroller.removeItemByFilePath(this.currentFilePath);
} else {
const newFileNameWithExt = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
const baseFileName = newFileNameWithExt.substring(0, newFileNameWithExt.lastIndexOf('.'));
const updateData = {
file_path: result.new_file_path,
file_name: baseFileName,
folder: newRelativeFolder
};
// Only update sub_type if it's present in the cache_entry
if (result.cache_entry && result.cache_entry.sub_type) {
updateData.sub_type = result.cache_entry.sub_type;
}
state.virtualScroller.updateSingleItem(this.currentFilePath, updateData);
}
}
} }
// Refresh folder tags after successful move // Refresh UI by reloading the current page, same as drag-and-drop behavior
sidebarManager.refresh(); // This ensures all metadata (like preview URLs) are correctly formatted by the backend
if (sidebarManager.pageControls && typeof sidebarManager.pageControls.resetAndReload === 'function') {
await sidebarManager.pageControls.resetAndReload(true);
} else if (sidebarManager.lastPageControls && typeof sidebarManager.lastPageControls.resetAndReload === 'function') {
await sidebarManager.lastPageControls.resetAndReload(true);
}
// Refresh folder tree in sidebar
await sidebarManager.refresh();
modalManager.closeModal('moveModal'); modalManager.closeModal('moveModal');
} catch (error) { } catch (error) {
console.error('Error moving model(s):', error); console.error('Error moving model(s):', error);
showToast('toast.models.moveFailed', { message: error.message }, 'error'); showToast('toast.models.moveFailed', { message: error.message }, 'error');

View File

@@ -1,13 +1,14 @@
// Recipe manager module // Recipe manager module
import { appCore } from './core.js'; import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js'; import { ImportManager } from './managers/ImportManager.js';
import { BatchImportManager } from './managers/BatchImportManager.js';
import { RecipeModal } from './components/RecipeModal.js'; import { RecipeModal } from './components/RecipeModal.js';
import { state, getCurrentPageState } from './state/index.js'; import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js'; import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.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'; import { sidebarManager } from './components/SidebarManager.js';
class RecipePageControls { class RecipePageControls {
@@ -27,7 +28,7 @@ class RecipePageControls {
return; return;
} }
refreshVirtualScroll(); await syncChanges();
} }
getSidebarApiClient() { getSidebarApiClient() {
@@ -46,6 +47,10 @@ class RecipeManager {
// Initialize ImportManager // Initialize ImportManager
this.importManager = new ImportManager(); this.importManager = new ImportManager();
// Initialize BatchImportManager and make it globally accessible
this.batchImportManager = new BatchImportManager();
window.batchImportManager = this.batchImportManager;
// Initialize RecipeModal // Initialize RecipeModal
this.recipeModal = new RecipeModal(); this.recipeModal = new RecipeModal();
@@ -236,6 +241,70 @@ class RecipeManager {
refreshVirtualScroll(); 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 // This method is kept for compatibility but now uses virtual scrolling

View File

@@ -0,0 +1,209 @@
/**
* Supporters service - Fetches and manages supporters data
*/
let supportersData = null;
let isLoading = false;
let loadPromise = null;
/**
* Fetch supporters data from the API
* @returns {Promise<Object>} Supporters data
*/
export async function fetchSupporters() {
// Return cached data if available
if (supportersData) {
return supportersData;
}
// Return existing promise if already loading
if (isLoading && loadPromise) {
return loadPromise;
}
isLoading = true;
loadPromise = fetch('/api/lm/supporters')
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch supporters: ${response.statusText}`);
}
return response.json();
})
.then(data => {
if (data.success && data.supporters) {
supportersData = data.supporters;
return supportersData;
}
throw new Error(data.error || 'Failed to load supporters data');
})
.catch(error => {
console.error('Error loading supporters:', error);
// Return empty data on error
return {
specialThanks: [],
allSupporters: [],
totalCount: 0
};
})
.finally(() => {
isLoading = false;
loadPromise = null;
});
return loadPromise;
}
/**
* Clear cached supporters data
*/
export function clearSupportersCache() {
supportersData = null;
}
let autoScrollRequest = null;
let autoScrollTimeout = null;
let isUserInteracting = false;
let isHovering = false;
let currentScrollPos = 0;
/**
* Handle user interaction to stop auto-scroll
*/
function handleInteraction() {
isUserInteracting = true;
}
/**
* Handle mouse enter to pause auto-scroll
*/
function handleMouseEnter() {
isHovering = true;
}
/**
* Handle mouse leave to resume auto-scroll
*/
function handleMouseLeave() {
isHovering = false;
}
/**
* Initialize auto-scrolling for the supporters list like movie credits
* @param {HTMLElement} container The scrollable container
*/
function initAutoScroll(container) {
if (!container) return;
// Stop any existing animation and clear any pending timeout
if (autoScrollRequest) {
cancelAnimationFrame(autoScrollRequest);
autoScrollRequest = null;
}
if (autoScrollTimeout) {
clearTimeout(autoScrollTimeout);
autoScrollTimeout = null;
}
// Reset state for new scroll
isUserInteracting = false;
isHovering = false;
container.scrollTop = 0;
currentScrollPos = 0;
const scrollSpeed = 0.4; // Pixels per frame (~24px/sec at 60fps)
const step = () => {
// Stop animation if container is hidden or no longer in DOM
if (!container.offsetParent) {
autoScrollRequest = null;
return;
}
if (!isHovering && !isUserInteracting) {
const prevScrollTop = container.scrollTop;
currentScrollPos += scrollSpeed;
container.scrollTop = currentScrollPos;
// Check if we reached the bottom
if (container.scrollTop === prevScrollTop && currentScrollPos > 1) {
const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 1;
if (isAtBottom) {
autoScrollRequest = null;
return;
}
}
} else {
// Keep currentScrollPos in sync if user scrolls manually or pauses
currentScrollPos = container.scrollTop;
}
autoScrollRequest = requestAnimationFrame(step);
};
// Remove existing listeners before adding to avoid duplicates
container.removeEventListener('mouseenter', handleMouseEnter);
container.removeEventListener('mouseleave', handleMouseLeave);
container.removeEventListener('wheel', handleInteraction);
container.removeEventListener('touchstart', handleInteraction);
container.removeEventListener('mousedown', handleInteraction);
// Event listeners to handle user control
container.addEventListener('mouseenter', handleMouseEnter);
container.addEventListener('mouseleave', handleMouseLeave);
// Use { passive: true } for better scroll performance
container.addEventListener('wheel', handleInteraction, { passive: true });
container.addEventListener('touchstart', handleInteraction, { passive: true });
container.addEventListener('mousedown', handleInteraction);
// Initial delay before starting the credits-style scroll
autoScrollTimeout = setTimeout(() => {
if (container.scrollHeight > container.clientHeight) {
autoScrollRequest = requestAnimationFrame(step);
}
}, 1800);
}
/**
* Render supporters in the support modal
*/
export async function renderSupporters() {
const supporters = await fetchSupporters();
// Update subtitle with total count
const subtitleEl = document.getElementById('supportersSubtitle');
if (subtitleEl) {
const originalText = subtitleEl.textContent;
subtitleEl.textContent = originalText.replace(/\d+/, supporters.totalCount);
}
// Render special thanks
const specialThanksGrid = document.getElementById('specialThanksGrid');
if (specialThanksGrid && supporters.specialThanks) {
specialThanksGrid.innerHTML = supporters.specialThanks
.map(supporter => `
<div class="supporter-special-card" title="${supporter}">
<span class="supporter-special-name">${supporter}</span>
</div>
`)
.join('');
}
// Render all supporters
const supportersGrid = document.getElementById('supportersGrid');
if (supportersGrid && supporters.allSupporters) {
supportersGrid.innerHTML = supporters.allSupporters
.map((supporter, index, array) => {
const separator = index < array.length - 1
? '<span class="supporter-separator">·</span>'
: '';
return `
<span class="supporter-name-item" title="${supporter}">${supporter}</span>${separator}
`;
})
.join('');
// Initialize the auto-scroll effect
initAutoScroll(supportersGrid);
}
}

View File

@@ -10,6 +10,11 @@ export class StatisticsManager {
this.charts = {}; this.charts = {};
this.data = {}; this.data = {};
this.initialized = false; this.initialized = false;
this.listStates = {
lora: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true },
checkpoint: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true },
embedding: { offset: 0, limit: 50, sort: 'desc', isLoading: false, hasMore: true }
};
} }
async initialize() { async initialize() {
@@ -24,7 +29,7 @@ export class StatisticsManager {
await this.loadAllData(); await this.loadAllData();
// Initialize charts and visualizations // Initialize charts and visualizations
this.initializeVisualizations(); await this.initializeVisualizations();
this.initialized = true; this.initialized = true;
} }
@@ -97,7 +102,7 @@ export class StatisticsManager {
return response.json(); return response.json();
} }
initializeVisualizations() { async initializeVisualizations() {
// Initialize metrics cards // Initialize metrics cards
this.renderMetricsCards(); this.renderMetricsCards();
@@ -105,7 +110,8 @@ export class StatisticsManager {
this.initializeCharts(); this.initializeCharts();
// Initialize lists and other components // Initialize lists and other components
this.renderTopModelsLists(); await this.initializeLists();
this.renderLargestModelsList();
this.renderTagCloud(); this.renderTagCloud();
this.renderInsights(); this.renderInsights();
} }
@@ -548,86 +554,87 @@ export class StatisticsManager {
}); });
} }
renderTopModelsLists() { async initializeLists() {
this.renderTopLorasList(); const listTypes = [
this.renderTopCheckpointsList(); { type: 'lora', containerId: 'topLorasList' },
this.renderTopEmbeddingsList(); { type: 'checkpoint', containerId: 'topCheckpointsList' },
this.renderLargestModelsList(); { type: 'embedding', containerId: 'topEmbeddingsList' }
];
const promises = listTypes.map(({ type, containerId }) => {
const container = document.getElementById(containerId);
if (container) {
// Handle infinite scrolling
container.addEventListener('scroll', () => {
if (container.scrollTop + container.clientHeight >= container.scrollHeight - 50) {
this.fetchAndRenderList(type, container);
}
});
// Initial fetch
return this.fetchAndRenderList(type, container);
}
return Promise.resolve();
});
await Promise.all(promises);
} }
renderTopLorasList() { async fetchAndRenderList(type, container) {
const container = document.getElementById('topLorasList'); const state = this.listStates[type];
if (!container || !this.data.usage?.top_loras) return; if (state.isLoading || !state.hasMore) return;
const topLoras = this.data.usage.top_loras; state.isLoading = true;
if (topLoras.length === 0) { // Show loading indicator on initial load
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>'; if (state.offset === 0) {
return; container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
} }
container.innerHTML = topLoras.map(lora => ` try {
<div class="model-item"> const url = `/api/lm/stats/model-usage-list?type=${type}&sort=${state.sort}&offset=${state.offset}&limit=${state.limit}`;
<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" const result = await this.fetchData(url);
alt="${lora.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${lora.name}">${lora.name}</div>
<div class="model-meta">${lora.base_model}${lora.folder}</div>
</div>
<div class="model-usage">${lora.usage_count}</div>
</div>
`).join('');
}
renderTopCheckpointsList() { if (result.success) {
const container = document.getElementById('topCheckpointsList'); const items = result.data.items;
if (!container || !this.data.usage?.top_checkpoints) return;
const topCheckpoints = this.data.usage.top_checkpoints; // Remove loading indicator if it's the first page
if (state.offset === 0) {
container.innerHTML = '';
}
if (topCheckpoints.length === 0) { if (items.length === 0 && state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>'; container.innerHTML = '<div class="loading-placeholder">No models found</div>';
return; state.hasMore = false;
} else if (items.length < state.limit) {
state.hasMore = false;
}
const html = items.map(model => `
<div class="model-item">
<img src="${model.preview_url || '/loras_static/images/no-preview.png'}"
alt="${model.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.base_model}${model.folder || 'Root'}</div>
</div>
<div class="model-usage">${model.usage_count}</div>
</div>
`).join('');
container.insertAdjacentHTML('beforeend', html);
state.offset += state.limit;
}
} catch (error) {
console.error(`Error loading ${type} list:`, error);
if (state.offset === 0) {
container.innerHTML = '<div class="loading-placeholder">Error loading data</div>';
}
} finally {
state.isLoading = false;
} }
container.innerHTML = topCheckpoints.map(checkpoint => `
<div class="model-item">
<img src="${checkpoint.preview_url || '/loras_static/images/no-preview.png'}"
alt="${checkpoint.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${checkpoint.name}">${checkpoint.name}</div>
<div class="model-meta">${checkpoint.base_model}${checkpoint.folder}</div>
</div>
<div class="model-usage">${checkpoint.usage_count}</div>
</div>
`).join('');
}
renderTopEmbeddingsList() {
const container = document.getElementById('topEmbeddingsList');
if (!container || !this.data.usage?.top_embeddings) return;
const topEmbeddings = this.data.usage.top_embeddings;
if (topEmbeddings.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
return;
}
container.innerHTML = topEmbeddings.map(embedding => `
<div class="model-item">
<img src="${embedding.preview_url || '/loras_static/images/no-preview.png'}"
alt="${embedding.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${embedding.name}">${embedding.name}</div>
<div class="model-meta">${embedding.base_model}${embedding.folder}</div>
</div>
<div class="model-usage">${embedding.usage_count}</div>
</div>
`).join('');
} }
renderLargestModelsList() { renderLargestModelsList() {

View File

@@ -0,0 +1,119 @@
/**
* CivitAI URL utilities
* Functions for working with CivitAI media URLs
*/
/**
* Optimization strategies for CivitAI URLs
*/
export const OptimizationMode = {
/** Full quality for showcase/display - uses /optimized=true only */
SHOWCASE: 'showcase',
/** Thumbnail size for cards - uses /width=450,optimized=true */
THUMBNAIL: 'thumbnail',
};
/**
* Rewrite Civitai preview URLs to use optimized renditions.
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
*
* @param {string|null} sourceUrl - Original preview URL from the Civitai API
* @param {string|null} mediaType - Optional media type hint ("image" or "video")
* @param {string} mode - Optimization mode ('showcase' or 'thumbnail')
* @returns {[string|null, boolean]} - Tuple of [rewritten URL or original, wasRewritten flag]
*/
export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = OptimizationMode.THUMBNAIL) {
if (!sourceUrl) {
return [sourceUrl, false];
}
try {
const url = new URL(sourceUrl);
// Check if it's a CivitAI image domain
if (url.hostname.toLowerCase() !== 'image.civitai.com') {
return [sourceUrl, false];
}
// Determine replacement based on mode and media type
let replacement;
if (mode === OptimizationMode.SHOWCASE) {
// Full quality for showcase - no width restriction
replacement = '/optimized=true';
} else {
// Thumbnail mode with width restriction
replacement = '/width=450,optimized=true';
if (mediaType && mediaType.toLowerCase() === 'video') {
replacement = '/transcode=true,width=450,optimized=true';
}
}
// Replace /original=true with optimized version
if (!url.pathname.includes('/original=true')) {
return [sourceUrl, false];
}
const updatedPath = url.pathname.replace('/original=true', replacement, 1);
if (updatedPath === url.pathname) {
return [sourceUrl, false];
}
url.pathname = updatedPath;
return [url.toString(), true];
} catch (e) {
// Invalid URL
return [sourceUrl, false];
}
}
/**
* Get the optimized URL for a media item, falling back to original if not a CivitAI URL
*
* @param {string} url - Original URL
* @param {string} type - Media type ("image" or "video")
* @param {string} mode - Optimization mode ('showcase' or 'thumbnail')
* @returns {string} - Optimized URL or original URL
*/
export function getOptimizedUrl(url, type = 'image', mode = OptimizationMode.THUMBNAIL) {
const [optimizedUrl] = rewriteCivitaiUrl(url, type, mode);
return optimizedUrl || url;
}
/**
* Get showcase-optimized URL (full quality)
*
* @param {string} url - Original URL
* @param {string} type - Media type ("image" or "video")
* @returns {string} - Optimized URL for showcase display
*/
export function getShowcaseUrl(url, type = 'image') {
return getOptimizedUrl(url, type, OptimizationMode.SHOWCASE);
}
/**
* Get thumbnail-optimized URL (width=450)
*
* @param {string} url - Original URL
* @param {string} type - Media type ("image" or "video")
* @returns {string} - Optimized URL for thumbnail display
*/
export function getThumbnailUrl(url, type = 'image') {
return getOptimizedUrl(url, type, OptimizationMode.THUMBNAIL);
}
/**
* Check if a URL is from CivitAI
*
* @param {string} url - URL to check
* @returns {boolean} - True if it's a CivitAI URL
*/
export function isCivitaiUrl(url) {
if (!url) return false;
try {
const parsed = new URL(url);
return parsed.hostname.toLowerCase() === 'image.civitai.com';
} catch (e) {
return false;
}
}

View File

@@ -7,7 +7,10 @@ let pendingExcludePath = null;
export function showDeleteModal(filePath) { export function showDeleteModal(filePath) {
pendingDeletePath = 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 modelName = card ? card.dataset.name : filePath.split('/').pop();
const modal = modalManager.getModal('deleteModal').element; const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info'); const modelInfo = modal.querySelector('.delete-model-info');
@@ -47,7 +50,10 @@ export function closeDeleteModal() {
export function showExcludeModal(filePath) { export function showExcludeModal(filePath) {
pendingExcludePath = 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 modelName = card ? card.dataset.name : filePath.split('/').pop();
const modal = modalManager.getModal('excludeModal').element; const modal = modalManager.getModal('excludeModal').element;
const modelInfo = modal.querySelector('.exclude-model-info'); const modelInfo = modal.querySelector('.exclude-model-info');

View File

@@ -197,7 +197,10 @@ export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
} }
export function openCivitai(filePath) { 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; if (!loraCard) return;
const metaData = JSON.parse(loraCard.dataset.meta); const metaData = JSON.parse(loraCard.dataset.meta);
@@ -483,8 +486,12 @@ async function ensureRelativeModelPath(modelPath, collectionType) {
return modelPath; 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 { 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) { if (!response.ok) {
return modelPath; return modelPath;
} }

View 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')">&times;</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>

View File

@@ -2,90 +2,133 @@
<div id="supportModal" class="modal"> <div id="supportModal" class="modal">
<div class="modal-content support-modal"> <div class="modal-content support-modal">
<button class="close" onclick="modalManager.closeModal('supportModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('supportModal')">&times;</button>
<div class="support-header">
<i class="fas fa-heart support-icon"></i>
<h2>{{ t('support.title') }}</h2>
</div>
<div class="support-content">
<p>{{ t('support.message') }}</p>
<div class="support-section"> <div class="support-container">
<h3><i class="fas fa-comment"></i> {{ t('support.feedback.title') }}</h3> <!-- Left Side: Support Options -->
<p>{{ t('support.feedback.description') }}</p> <div class="support-left">
<div class="support-links"> <div class="support-header">
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/issues/new" class="social-link" target="_blank"> <i class="fas fa-heart support-icon"></i>
<i class="fab fa-github"></i> <h2>{{ t('support.title') }}</h2>
<span>{{ t('support.links.submitGithubIssue') }}</span> </div>
</a> <div class="support-content">
<a href="https://discord.gg/vcqNrWVFvM" class="social-link" target="_blank"> <p>{{ t('support.message') }}</p>
<i class="fab fa-discord"></i>
<span>{{ t('support.links.joinDiscord') }}</span> <div class="support-section">
</a> <h3><i class="fas fa-comment"></i> {{ t('support.feedback.title') }}</h3>
<p>{{ t('support.feedback.description') }}</p>
<div class="support-links">
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/issues/new" class="social-link" target="_blank">
<i class="fab fa-github"></i>
<span>{{ t('support.links.submitGithubIssue') }}</span>
</a>
<a href="https://discord.gg/vcqNrWVFvM" class="social-link" target="_blank">
<i class="fab fa-discord"></i>
<span>{{ t('support.links.joinDiscord') }}</span>
</a>
</div>
</div>
<div class="support-section">
<h3><i class="fas fa-rss"></i> {{ t('support.sections.followUpdates') }}</h3>
<div class="support-links">
<a href="https://www.youtube.com/@pixelpaws-ai" class="social-link" target="_blank">
<i class="fab fa-youtube"></i>
<span>{{ t('support.links.youtubeChannel') }}</span>
</a>
<a href="https://civitai.com/user/PixelPawsAI" class="social-link civitai-link" target="_blank">
<svg class="civitai-icon" viewBox="0 0 225 225" width="20" height="20">
<g transform="translate(0,225) scale(0.1,-0.1)" fill="currentColor">
<path d="M950 1899 c-96 -55 -262 -150 -367 -210 -106 -61 -200 -117 -208
-125 -13 -13 -15 -76 -15 -443 0 -395 1 -429 18 -443 9 -9 116 -73 237 -143
121 -70 283 -163 359 -208 76 -45 146 -80 155 -80 9 1 183 98 386 215 l370
215 2 444 3 444 -376 215 c-206 118 -378 216 -382 217 -4 1 -86 -43 -182 -98z
m346 -481 l163 -93 1 -57 0 -58 -89 0 c-87 0 -91 1 -166 44 l-78 45 -51 -30
c-28 -17 -61 -35 -73 -41 -21 -10 -23 -18 -23 -99 l0 -87 71 -41 c39 -23 73
-41 76 -41 3 0 37 18 75 40 68 39 72 40 164 40 l94 0 0 -53 c0 -60 23 -41
-198 -168 l-133 -77 -92 52 c-51 29 -126 73 -167 97 l-75 45 0 193 0 192 164
95 c91 52 167 94 169 94 2 0 78 -42 168 -92z"/>
</g>
</svg>
<span>{{ t('support.links.civitaiProfile') }}</span>
</a>
</div>
</div>
<div class="support-section">
<h3><i class="fas fa-coffee"></i> {{ t('support.sections.buyMeCoffee') }}</h3>
<p>{{ t('support.sections.coffeeDescription') }}</p>
<a href="https://ko-fi.com/pixelpawsai" class="kofi-button" target="_blank">
<i class="fas fa-mug-hot"></i>
<span>{{ t('support.links.supportKofi') }}</span>
</a>
</div>
<!-- Patreon Support Section -->
<div class="support-section">
<h3><i class="fab fa-patreon"></i> {{ t('support.sections.becomePatron') }}</h3>
<p>{{ t('support.sections.patronDescription') }}</p>
<a href="https://patreon.com/PixelPawsAI" class="patreon-button" target="_blank">
<i class="fab fa-patreon"></i>
<span>{{ t('support.links.supportPatreon') }}</span>
</a>
</div>
<!-- New section for Chinese payment methods -->
<div class="support-section">
<h3><i class="fas fa-qrcode"></i> {{ t('support.sections.wechatSupport') }}</h3>
<p>{{ t('support.sections.wechatDescription') }}</p>
<button class="secondary-btn qrcode-toggle" id="toggleQRCode">
<i class="fas fa-qrcode"></i>
<span class="toggle-text">{{ t('support.sections.showWechatQR') }}</span>
<i class="fas fa-chevron-down toggle-icon"></i>
</button>
<div class="qrcode-container" id="qrCodeContainer">
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
</div>
</div>
<div class="support-footer">
<p>{{ t('support.footer') }}</p>
</div>
</div> </div>
</div> </div>
<div class="support-section"> <!-- Right Side: Supporters -->
<h3><i class="fas fa-rss"></i> {{ t('support.sections.followUpdates') }}</h3> <div class="support-right">
<div class="support-links"> <div class="supporters-section">
<a href="https://www.youtube.com/@pixelpaws-ai" class="social-link" target="_blank"> <div class="supporters-header">
<i class="fab fa-youtube"></i> <h2 class="supporters-title">
<span>{{ t('support.links.youtubeChannel') }}</span> <i class="fas fa-hands-helping"></i>
</a> {{ t('support.supporters.title') }}
<a href="https://civitai.com/user/PixelPawsAI" class="social-link civitai-link" target="_blank"> </h2>
<svg class="civitai-icon" viewBox="0 0 225 225" width="20" height="20"> <p class="supporters-subtitle" id="supportersSubtitle">
<g transform="translate(0,225) scale(0.1,-0.1)" fill="currentColor"> {{ t('support.supporters.subtitle', count=0) }}
<path d="M950 1899 c-96 -55 -262 -150 -367 -210 -106 -61 -200 -117 -208 </p>
-125 -13 -13 -15 -76 -15 -443 0 -395 1 -429 18 -443 9 -9 116 -73 237 -143 </div>
121 -70 283 -163 359 -208 76 -45 146 -80 155 -80 9 1 183 98 386 215 l370
215 2 444 3 444 -376 215 c-206 118 -378 216 -382 217 -4 1 -86 -43 -182 -98z <!-- Special Thanks Section -->
m346 -481 l163 -93 1 -57 0 -58 -89 0 c-87 0 -91 1 -166 44 l-78 45 -51 -30 <div class="supporters-group special-thanks-group">
c-28 -17 -61 -35 -73 -41 -21 -10 -23 -18 -23 -99 l0 -87 71 -41 c39 -23 73 <h3 class="supporters-group-title">
-41 76 -41 3 0 37 18 75 40 68 39 72 40 164 40 l94 0 0 -53 c0 -60 23 -41 <i class="fas fa-star"></i>
-198 -168 l-133 -77 -92 52 c-51 29 -126 73 -167 97 l-75 45 0 193 0 192 164 {{ t('support.supporters.specialThanks') }}
95 c91 52 167 94 169 94 2 0 78 -42 168 -92z"/> </h3>
</g> <div class="supporters-special-grid" id="specialThanksGrid">
</svg> <!-- Supporters will be loaded dynamically -->
<span>{{ t('support.links.civitaiProfile') }}</span> </div>
</a> </div>
<!-- All Supporters Section -->
<div class="supporters-group all-supporters-group">
<h3 class="supporters-group-title">
<i class="fas fa-heart"></i>
{{ t('support.supporters.allSupporters') }}
</h3>
<div class="supporters-all-list" id="supportersGrid">
<!-- Supporters will be loaded dynamically -->
</div>
</div>
</div> </div>
</div> </div>
<div class="support-section">
<h3><i class="fas fa-coffee"></i> {{ t('support.sections.buyMeCoffee') }}</h3>
<p>{{ t('support.sections.coffeeDescription') }}</p>
<a href="https://ko-fi.com/pixelpawsai" class="kofi-button" target="_blank">
<i class="fas fa-mug-hot"></i>
<span>{{ t('support.links.supportKofi') }}</span>
</a>
</div>
<!-- Patreon Support Section -->
<div class="support-section">
<h3><i class="fab fa-patreon"></i> {{ t('support.sections.becomePatron') }}</h3>
<p>{{ t('support.sections.patronDescription') }}</p>
<a href="https://patreon.com/PixelPawsAI" class="patreon-button" target="_blank">
<i class="fab fa-patreon"></i>
<span>{{ t('support.links.supportPatreon') }}</span>
</a>
</div>
<!-- New section for Chinese payment methods -->
<div class="support-section">
<h3><i class="fas fa-qrcode"></i> {{ t('support.sections.wechatSupport') }}</h3>
<p>{{ t('support.sections.wechatDescription') }}</p>
<button class="secondary-btn qrcode-toggle" id="toggleQRCode">
<i class="fas fa-qrcode"></i>
<span class="toggle-text">{{ t('support.sections.showWechatQR') }}</span>
<i class="fas fa-chevron-down toggle-icon"></i>
</button>
<div class="qrcode-container" id="qrCodeContainer">
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
</div>
</div>
<div class="support-footer">
<p>{{ t('support.footer') }}</p>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,10 +7,12 @@
<link rel="stylesheet" href="/loras_static/css/components/card.css?v={{ version }}"> <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/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/import-modal.css?v={{ version }}">
<link rel="stylesheet" href="/loras_static/css/components/batch-import-modal.css?v={{ version }}">
{% endblock %} {% endblock %}
{% block additional_components %} {% block additional_components %}
{% include 'components/import_modal.html' %} {% include 'components/import_modal.html' %}
{% include 'components/batch_import_modal.html' %}
{% include 'components/recipe_modal.html' %} {% include 'components/recipe_modal.html' %}
<div id="recipeContextMenu" class="context-menu" style="display: none;"> <div id="recipeContextMenu" class="context-menu" style="display: none;">
@@ -66,15 +68,29 @@
</optgroup> </optgroup>
</select> </select>
</div> </div>
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group"> <div title="{{ t('recipes.controls.refresh.title') }}" class="control-group dropdown-group">
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ <button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{
t('common.actions.refresh') t('common.actions.refresh') }}</span></button>
}}</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>
<div title="{{ t('recipes.controls.import.title') }}" class="control-group"> <div title="{{ t('recipes.controls.import.title') }}" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ <button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
t('recipes.controls.import.action') }}</button> t('recipes.controls.import.action') }}</button>
</div> </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') }}"> <div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
<button id="bulkOperationsBtn" data-action="bulk" 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> <i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>

View File

@@ -90,7 +90,7 @@ describe('AutoComplete widget interactions', () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
await Promise.resolve(); 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'); const items = autoComplete.dropdown.querySelectorAll('.comfy-autocomplete-item');
expect(items).toHaveLength(1); expect(items).toHaveLength(1);
expect(autoComplete.dropdown.style.display).toBe('block'); expect(autoComplete.dropdown.style.display).toBe('block');
@@ -156,4 +156,542 @@ describe('AutoComplete widget interactions', () => {
expect(highlighted).toContain('detail'); expect(highlighted).toContain('detail');
expect(highlighted).not.toMatch(/beta<\/span>/i); 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, ');
});
}); });

View File

@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { moveManager } from '../../../static/js/managers/MoveManager.js';
import { state } from '../../../static/js/state/index.js';
import { modalManager } from '../../../static/js/managers/ModalManager.js';
import { getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
import * as storageHelpers from '../../../static/js/utils/storageHelpers.js';
// Mock dependencies
vi.mock('../../../static/js/state/index.js', () => ({
state: {
currentPageType: 'loras',
selectedModels: new Set(),
global: {
settings: {
download_path_templates: {
lora: '{base_model}/unstaged'
}
}
}
}
}));
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn()
}
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn()
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(),
setStorageItem: vi.fn()
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn()
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn(key => key)
}));
describe('MoveManager', () => {
let mockApiClient;
beforeEach(() => {
vi.clearAllMocks();
// Setup DOM
document.body.innerHTML = `
<div id="moveModal">
<h2 id="moveModalTitle"></h2>
<label id="moveRootLabel"></label>
<select id="moveModelRoot"></select>
<input type="checkbox" id="moveUseDefaultPath" />
<div id="moveManualPathSelection">
<input id="moveFolderPath" />
<div id="moveFolderTree"></div>
</div>
<div id="moveTargetPathDisplay"><span class="path-text"></span></div>
</div>
`;
mockApiClient = {
apiConfig: {
config: {
displayName: 'LoRA',
supportsMove: true
},
endpoints: {
moveModel: '/api/move'
}
},
modelType: 'loras',
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/models/loras'] }),
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ success: true, tree: {} }),
moveSingleModel: vi.fn().mockResolvedValue({ success: true })
};
getModelApiClient.mockReturnValue(mockApiClient);
});
it('should reset folder selection when showing move modal', async () => {
// Manually set a selected path in folderTreeManager
moveManager.folderTreeManager.selectedPath = 'previous/path';
await moveManager.showMoveModal('some/file.safetensors');
expect(moveManager.folderTreeManager.getSelectedPath()).toBe('');
});
it('should ignore manual folder selection when useDefaultPath is true', async () => {
// Setup state
moveManager.useDefaultPath = true;
moveManager.currentFilePath = '/models/loras/flux/my-lora.safetensors';
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
// Manually set a selected path despite useDefaultPath being true
moveManager.folderTreeManager.selectedPath = 'wrong/folder';
await moveManager.moveModel();
// Should call moveSingleModel with the root path, NOT including the 'wrong/folder'
expect(mockApiClient.moveSingleModel).toHaveBeenCalledWith(
'/models/loras/flux/my-lora.safetensors',
'/models/loras',
true
);
});
it('should include manual folder selection when useDefaultPath is false', async () => {
// Setup state
moveManager.useDefaultPath = false;
moveManager.currentFilePath = '/models/loras/flux/my-lora.safetensors';
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
// Set a selected path
moveManager.folderTreeManager.selectedPath = 'my/organized/folder';
await moveManager.moveModel();
// Should call moveSingleModel with root + selected folder
expect(mockApiClient.moveSingleModel).toHaveBeenCalledWith(
'/models/loras/flux/my-lora.safetensors',
'/models/loras/my/organized/folder',
false
);
});
it('should handle bulk move and ignore manual folder selection when useDefaultPath is true', async () => {
// Setup state
moveManager.useDefaultPath = true;
moveManager.bulkFilePaths = [
'/models/loras/flux/lora1.safetensors',
'/models/loras/flux/lora2.safetensors'
];
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
document.getElementById('moveModelRoot').value = '/models/loras';
// Manually set a selected path
moveManager.folderTreeManager.selectedPath = 'wrong/folder';
mockApiClient.moveBulkModels = vi.fn().mockResolvedValue({ success: true });
await moveManager.moveModel();
// Should call moveBulkModels with the root path, NOT including the 'wrong/folder'
expect(mockApiClient.moveBulkModels).toHaveBeenCalledWith(
moveManager.bulkFilePaths,
'/models/loras',
true
);
});
});

View File

@@ -107,6 +107,33 @@ describe('Statistics dashboard rendering', () => {
], ],
}, },
}, },
'/api/lm/stats/model-usage-list?type=lora&sort=desc&offset=0&limit=50': {
success: true,
data: {
items: [
{ name: 'Lora A', base_model: 'SDXL', folder: 'loras', usage_count: 10, preview_url: '' },
],
total: 1,
},
},
'/api/lm/stats/model-usage-list?type=checkpoint&sort=desc&offset=0&limit=50': {
success: true,
data: {
items: [
{ name: 'Checkpoint A', base_model: 'SDXL', folder: 'checkpoints', usage_count: 5, preview_url: '' },
],
total: 1,
},
},
'/api/lm/stats/model-usage-list?type=embedding&sort=desc&offset=0&limit=50': {
success: true,
data: {
items: [
{ name: 'Embedding A', base_model: 'SDXL', folder: 'embeddings', usage_count: 7, preview_url: '' },
],
total: 1,
},
},
}; };
const { StatisticsManager } = await import(STATISTICS_MODULE); const { StatisticsManager } = await import(STATISTICS_MODULE);

View File

@@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import {
rewriteCivitaiUrl,
getOptimizedUrl,
getShowcaseUrl,
getThumbnailUrl,
isCivitaiUrl,
OptimizationMode
} from '../../../static/js/utils/civitaiUtils.js';
describe('civitaiUtils', () => {
describe('OptimizationMode', () => {
it('should have correct mode values', () => {
expect(OptimizationMode.SHOWCASE).toBe('showcase');
expect(OptimizationMode.THUMBNAIL).toBe('thumbnail');
});
});
describe('rewriteCivitaiUrl', () => {
it('should rewrite image URLs with /original=true for thumbnail mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
expect(wasRewritten).toBe(true);
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450,optimized=true/12345.jpeg');
});
it('should rewrite image URLs with /original=true for showcase mode (no width)', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.SHOWCASE);
expect(wasRewritten).toBe(true);
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/optimized=true/12345.jpeg');
});
it('should rewrite video URLs with /original=true for thumbnail mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'video', OptimizationMode.THUMBNAIL);
expect(wasRewritten).toBe(true);
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/transcode=true,width=450,optimized=true/12345.mp4');
});
it('should rewrite video URLs with /original=true for showcase mode (no width/transcode)', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'video', OptimizationMode.SHOWCASE);
expect(wasRewritten).toBe(true);
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/optimized=true/12345.mp4');
});
it('should default to thumbnail mode when mode is not specified', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image');
expect(wasRewritten).toBe(true);
expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450,optimized=true/12345.jpeg');
});
it('should not rewrite URLs without /original=true', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450/12345.jpeg';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
expect(wasRewritten).toBe(false);
expect(rewritten).toBe(originalUrl);
});
it('should not rewrite non-CivitAI URLs', () => {
const originalUrl = 'https://example.com/image.jpg';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.SHOWCASE);
expect(wasRewritten).toBe(false);
expect(rewritten).toBe(originalUrl);
});
it('should handle null/undefined URLs', () => {
const [rewritten1, wasRewritten1] = rewriteCivitaiUrl(null, 'image');
expect(wasRewritten1).toBe(false);
expect(rewritten1).toBe(null);
const [rewritten2, wasRewritten2] = rewriteCivitaiUrl(undefined, 'image');
expect(wasRewritten2).toBe(false);
expect(rewritten2).toBe(undefined);
});
it('should handle empty strings', () => {
const [rewritten, wasRewritten] = rewriteCivitaiUrl('', 'image');
expect(wasRewritten).toBe(false);
expect(rewritten).toBe('');
});
it('should handle invalid URLs gracefully', () => {
const [rewritten, wasRewritten] = rewriteCivitaiUrl('not-a-valid-url', 'image');
expect(wasRewritten).toBe(false);
expect(rewritten).toBe('not-a-valid-url');
});
});
describe('getOptimizedUrl', () => {
it('should return optimized URL for CivitAI images in thumbnail mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
const optimized = getOptimizedUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL);
expect(optimized).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450,optimized=true/12345.jpeg');
});
it('should return optimized URL for CivitAI images in showcase mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
const optimized = getOptimizedUrl(originalUrl, 'image', OptimizationMode.SHOWCASE);
expect(optimized).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/optimized=true/12345.jpeg');
});
it('should return original URL for non-CivitAI URLs', () => {
const originalUrl = 'https://example.com/image.jpg';
const optimized = getOptimizedUrl(originalUrl, 'image');
expect(optimized).toBe(originalUrl);
});
});
describe('getShowcaseUrl', () => {
it('should return showcase-optimized URL (full quality)', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
const showcaseUrl = getShowcaseUrl(originalUrl, 'image');
expect(showcaseUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/optimized=true/12345.jpeg');
});
it('should handle videos for showcase', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
const showcaseUrl = getShowcaseUrl(originalUrl, 'video');
expect(showcaseUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/optimized=true/12345.mp4');
});
});
describe('getThumbnailUrl', () => {
it('should return thumbnail-optimized URL (width=450)', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
const thumbnailUrl = getThumbnailUrl(originalUrl, 'image');
expect(thumbnailUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450,optimized=true/12345.jpeg');
});
it('should handle videos for thumbnails', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4';
const thumbnailUrl = getThumbnailUrl(originalUrl, 'video');
expect(thumbnailUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/transcode=true,width=450,optimized=true/12345.mp4');
});
});
describe('isCivitaiUrl', () => {
it('should return true for CivitAI URLs', () => {
expect(isCivitaiUrl('https://image.civitai.com/something')).toBe(true);
expect(isCivitaiUrl('https://image.civitai.com/')).toBe(true);
});
it('should return false for non-CivitAI URLs', () => {
expect(isCivitaiUrl('https://example.com/image.jpg')).toBe(false);
expect(isCivitaiUrl('https://civitai.com/image.jpg')).toBe(false);
expect(isCivitaiUrl('')).toBe(false);
expect(isCivitaiUrl(null)).toBe(false);
expect(isCivitaiUrl(undefined)).toBe(false);
});
it('should handle invalid URLs gracefully', () => {
expect(isCivitaiUrl('not-a-url')).toBe(false);
});
});
});

View File

@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
describe('Version Detection Logic', () => {
const parseVersion = (versionStr) => {
if (!versionStr || typeof versionStr !== 'string') {
return [0, 0, 0];
}
const cleanVersion = versionStr.replace(/^[vV]/, '').split('-')[0];
const parts = cleanVersion.split('.').map(part => parseInt(part, 10) || 0);
while (parts.length < 3) {
parts.push(0);
}
return parts;
};
const compareVersions = (version1, version2) => {
const v1 = typeof version1 === 'string' ? parseVersion(version1) : version1;
const v2 = typeof version2 === 'string' ? parseVersion(version2) : version2;
for (let i = 0; i < 3; i++) {
if (v1[i] > v2[i]) return 1;
if (v1[i] < v2[i]) return -1;
}
return 0;
};
const MIN_VERSION_FOR_ACTION_BAR = [1, 33, 9];
const supportsActionBarButtons = (version) => {
return compareVersions(version, MIN_VERSION_FOR_ACTION_BAR) >= 0;
};
it('should parse version strings correctly', () => {
expect(parseVersion('1.33.9')).toEqual([1, 33, 9]);
expect(parseVersion('v1.33.9')).toEqual([1, 33, 9]);
expect(parseVersion('1.33.9-beta')).toEqual([1, 33, 9]);
expect(parseVersion('1.33')).toEqual([1, 33, 0]);
expect(parseVersion('1')).toEqual([1, 0, 0]);
expect(parseVersion('')).toEqual([0, 0, 0]);
expect(parseVersion(null)).toEqual([0, 0, 0]);
});
it('should compare versions correctly', () => {
expect(compareVersions('1.33.9', '1.33.9')).toBe(0);
expect(compareVersions('1.33.10', '1.33.9')).toBe(1);
expect(compareVersions('1.34.0', '1.33.9')).toBe(1);
expect(compareVersions('2.0.0', '1.33.9')).toBe(1);
expect(compareVersions('1.33.8', '1.33.9')).toBe(-1);
expect(compareVersions('1.32.0', '1.33.9')).toBe(-1);
expect(compareVersions('0.9.9', '1.33.9')).toBe(-1);
});
it('should return false for versions below 1.33.9', () => {
expect(supportsActionBarButtons('1.33.8')).toBe(false);
expect(supportsActionBarButtons('1.32.0')).toBe(false);
expect(supportsActionBarButtons('0.9.9')).toBe(false);
});
it('should return true for versions 1.33.9 and above', () => {
expect(supportsActionBarButtons('1.33.9')).toBe(true);
expect(supportsActionBarButtons('1.33.10')).toBe(true);
expect(supportsActionBarButtons('1.34.0')).toBe(true);
expect(supportsActionBarButtons('2.0.0')).toBe(true);
});
it('should handle edge cases in version parsing', () => {
expect(supportsActionBarButtons('v1.33.9')).toBe(true);
expect(supportsActionBarButtons('1.33.9-rc.1')).toBe(true);
expect(supportsActionBarButtons('1.33.9-beta')).toBe(true);
});
});

View File

@@ -14,12 +14,17 @@ from py.services.model_hash_index import ModelHashIndex
from py.utils.utils import fuzzy_match, calculate_recipe_fingerprint from py.utils.utils import fuzzy_match, calculate_recipe_fingerprint
pytestmark = pytest.mark.performance
class TestHashIndexPerformance: class TestHashIndexPerformance:
"""Performance benchmarks for hash index operations.""" """Performance benchmarks for hash index operations."""
def test_hash_index_lookup_small(self, benchmark): def test_hash_index_lookup_small(self, benchmark):
"""Benchmark hash index lookup with 100 models.""" """Benchmark hash index lookup with 100 models."""
index, target_hash = self._create_hash_index_with_n_models(100, return_target=True) index, target_hash = self._create_hash_index_with_n_models(
100, return_target=True
)
def lookup(): def lookup():
return index.get_path(target_hash) return index.get_path(target_hash)
@@ -29,7 +34,9 @@ class TestHashIndexPerformance:
def test_hash_index_lookup_medium(self, benchmark): def test_hash_index_lookup_medium(self, benchmark):
"""Benchmark hash index lookup with 1,000 models.""" """Benchmark hash index lookup with 1,000 models."""
index, target_hash = self._create_hash_index_with_n_models(1000, return_target=True) index, target_hash = self._create_hash_index_with_n_models(
1000, return_target=True
)
def lookup(): def lookup():
return index.get_path(target_hash) return index.get_path(target_hash)
@@ -39,7 +46,9 @@ class TestHashIndexPerformance:
def test_hash_index_lookup_large(self, benchmark): def test_hash_index_lookup_large(self, benchmark):
"""Benchmark hash index lookup with 10,000 models.""" """Benchmark hash index lookup with 10,000 models."""
index, target_hash = self._create_hash_index_with_n_models(10000, return_target=True) index, target_hash = self._create_hash_index_with_n_models(
10000, return_target=True
)
def lookup(): def lookup():
return index.get_path(target_hash) return index.get_path(target_hash)
@@ -94,7 +103,7 @@ class TestHashIndexPerformance:
def _random_string(self, length: int) -> str: def _random_string(self, length: int) -> str:
"""Generate a random string of fixed length.""" """Generate a random string of fixed length."""
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
class TestFuzzyMatchPerformance: class TestFuzzyMatchPerformance:

View File

@@ -31,7 +31,9 @@ from py.utils.metadata_manager import MetadataManager
class DummyRoutes(BaseModelRoutes): class DummyRoutes(BaseModelRoutes):
template_name = "dummy.html" template_name = "dummy.html"
def setup_specific_routes(self, registrar, prefix: str) -> None: # pragma: no cover - no extra routes in smoke tests def setup_specific_routes(
self, registrar, prefix: str
) -> None: # pragma: no cover - no extra routes in smoke tests
return None return None
def __init__(self, service=None): def __init__(self, service=None):
@@ -59,7 +61,9 @@ class NullUpdateRecord:
@property @property
def in_library_version_ids(self) -> list[int]: def in_library_version_ids(self) -> list[int]:
return [version.version_id for version in self.versions if version.is_in_library] return [
version.version_id for version in self.versions if version.is_in_library
]
def has_update(self) -> bool: def has_update(self) -> bool:
return False return False
@@ -86,7 +90,9 @@ class NullModelUpdateService:
) )
for version_id in version_ids for version_id in version_ids
] ]
return NullUpdateRecord(model_type=model_type, model_id=model_id, versions=versions) return NullUpdateRecord(
model_type=model_type, model_id=model_id, versions=versions
)
async def set_should_ignore(self, model_type, model_id, should_ignore): async def set_should_ignore(self, model_type, model_id, should_ignore):
return NullUpdateRecord( return NullUpdateRecord(
@@ -95,7 +101,9 @@ class NullModelUpdateService:
should_ignore_model=should_ignore, should_ignore_model=should_ignore,
) )
async def set_version_should_ignore(self, model_type, model_id, version_id, should_ignore): async def set_version_should_ignore(
self, model_type, model_id, version_id, should_ignore
):
return await self.set_should_ignore(model_type, model_id, should_ignore) return await self.set_should_ignore(model_type, model_id, should_ignore)
async def get_record(self, *args, **kwargs): async def get_record(self, *args, **kwargs):
@@ -167,7 +175,9 @@ def download_manager_stub():
def test_list_models_returns_formatted_items(mock_service, mock_scanner): def test_list_models_returns_formatted_items(mock_service, mock_scanner):
mock_service.paginated_items = [{"file_path": "/tmp/demo.safetensors", "name": "Demo"}] mock_service.paginated_items = [
{"file_path": "/tmp/demo.safetensors", "name": "Demo"}
]
async def scenario(): async def scenario():
client = await create_test_client(mock_service) client = await create_test_client(mock_service)
@@ -176,7 +186,13 @@ def test_list_models_returns_formatted_items(mock_service, mock_scanner):
payload = await response.json() payload = await response.json()
assert response.status == 200 assert response.status == 200
assert payload["items"] == [{"file_path": "/tmp/demo.safetensors", "name": "Demo", "formatted": True}] assert payload["items"] == [
{
"file_path": "/tmp/demo.safetensors",
"name": "Demo",
"formatted": True,
}
]
assert payload["total"] == 1 assert payload["total"] == 1
assert mock_service.formatted == payload["items"] assert mock_service.formatted == payload["items"]
finally: finally:
@@ -220,7 +236,9 @@ def test_routes_return_service_not_ready_when_unattached():
asyncio.run(scenario()) asyncio.run(scenario())
def test_delete_model_updates_cache_and_hash_index(mock_service, mock_scanner, tmp_path: Path): def test_delete_model_updates_cache_and_hash_index(
mock_service, mock_scanner, tmp_path: Path
):
model_path = tmp_path / "sample.safetensors" model_path = tmp_path / "sample.safetensors"
model_path.write_bytes(b"model") model_path.write_bytes(b"model")
mock_scanner._cache.raw_data = [{"file_path": str(model_path)}] mock_scanner._cache.raw_data = [{"file_path": str(model_path)}]
@@ -271,17 +289,23 @@ def test_replace_preview_writes_file_and_updates_cache(
) )
form = FormData() form = FormData()
form.add_field("preview_file", b"binary-data", filename="preview.png", content_type="image/png") form.add_field(
"preview_file", b"binary-data", filename="preview.png", content_type="image/png"
)
form.add_field("model_path", str(model_path)) form.add_field("model_path", str(model_path))
form.add_field("nsfw_level", "2") form.add_field("nsfw_level", "2")
async def scenario(): async def scenario():
client = await create_test_client(mock_service) client = await create_test_client(mock_service)
try: try:
response = await client.post("/api/lm/test-models/replace-preview", data=form) response = await client.post(
"/api/lm/test-models/replace-preview", data=form
)
payload = await response.json() payload = await response.json()
expected_preview = str((tmp_path / "preview-model.webp")).replace(os.sep, "/") expected_preview = str((tmp_path / "preview-model.webp")).replace(
os.sep, "/"
)
assert response.status == 200 assert response.status == 200
assert payload["success"] is True assert payload["success"] is True
assert payload["preview_url"] == "/static/preview-model.webp" assert payload["preview_url"] == "/static/preview-model.webp"
@@ -299,6 +323,66 @@ def test_replace_preview_writes_file_and_updates_cache(
asyncio.run(scenario()) asyncio.run(scenario())
def test_set_preview_from_url_downloads_and_updates_cache(
mock_service,
mock_scanner,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
):
"""Test that set_preview_from_url endpoint downloads remote images and sets them as preview."""
model_path = tmp_path / "url-preview-model.safetensors"
model_path.write_bytes(b"model")
metadata_path = tmp_path / "url-preview-model.metadata.json"
metadata_path.write_text(json.dumps({"file_path": str(model_path)}))
mock_scanner._cache.raw_data = [{"file_path": str(model_path)}]
monkeypatch.setattr(
config,
"get_preview_static_url",
lambda preview_path: f"/static/{Path(preview_path).name}",
)
async def scenario():
client = await create_test_client(mock_service)
try:
# Mock the Downloader to return a test image
from py.services import downloader
class FakeDownloader:
async def download_to_memory(
self, url, use_auth=False, return_headers=True
):
return True, b"fake-image-data", {"Content-Type": "image/jpeg"}
async def fake_get_downloader():
return FakeDownloader()
monkeypatch.setattr(downloader, "get_downloader", fake_get_downloader)
response = await client.post(
"/api/lm/test-models/set-preview-from-url",
json={
"model_path": str(model_path),
"image_url": "https://example.com/image.jpg",
"nsfw_level": 3,
},
)
payload = await response.json()
expected_preview = str((tmp_path / "url-preview-model.webp")).replace(
os.sep, "/"
)
assert response.status == 200
assert payload["success"] is True
assert payload["preview_url"] == "/static/url-preview-model.webp"
assert Path(expected_preview).exists()
finally:
await client.close()
asyncio.run(scenario())
def test_fetch_civitai_hydrates_metadata_before_sync( def test_fetch_civitai_hydrates_metadata_before_sync(
mock_service, mock_service,
mock_scanner, mock_scanner,
@@ -370,9 +454,15 @@ def test_fetch_civitai_hydrates_metadata_before_sync(
save_calls: list[tuple[str, dict]] = [] save_calls: list[tuple[str, dict]] = []
captured: dict[str, dict] = {} captured: dict[str, dict] = {}
monkeypatch.setattr(MetadataManager, "load_metadata", staticmethod(fake_load_metadata)) monkeypatch.setattr(
monkeypatch.setattr(MetadataManager, "save_metadata", staticmethod(fake_save_metadata)) MetadataManager, "load_metadata", staticmethod(fake_load_metadata)
monkeypatch.setattr(MetadataSyncService, "fetch_and_update_model", fake_fetch_and_update_model) )
monkeypatch.setattr(
MetadataManager, "save_metadata", staticmethod(fake_save_metadata)
)
monkeypatch.setattr(
MetadataSyncService, "fetch_and_update_model", fake_fetch_and_update_model
)
async def scenario(): async def scenario():
client = await create_test_client(mock_service) client = await create_test_client(mock_service)
@@ -386,7 +476,10 @@ def test_fetch_civitai_hydrates_metadata_before_sync(
assert response.status == 200 assert response.status == 200
assert payload["success"] is True assert payload["success"] is True
assert captured["model_data"]["custom_field"] == "preserve" assert captured["model_data"]["custom_field"] == "preserve"
assert captured["model_data"]["civitai"]["images"][0]["url"] == "https://example.com/existing.png" assert (
captured["model_data"]["civitai"]["images"][0]["url"]
== "https://example.com/existing.png"
)
assert captured["model_data"]["civitai"]["trainedWords"] == ["keep"] assert captured["model_data"]["civitai"]["trainedWords"] == ["keep"]
assert captured["model_data"]["civitai"]["id"] == 99 assert captured["model_data"]["civitai"]["id"] == 99
finally: finally:
@@ -398,7 +491,10 @@ def test_fetch_civitai_hydrates_metadata_before_sync(
saved_path, saved_payload = save_calls[0] saved_path, saved_payload = save_calls[0]
assert saved_path == str(metadata_path) assert saved_path == str(metadata_path)
assert saved_payload["custom_field"] == "preserve" assert saved_payload["custom_field"] == "preserve"
assert saved_payload["civitai"]["images"][0]["url"] == "https://example.com/existing.png" assert (
saved_payload["civitai"]["images"][0]["url"]
== "https://example.com/existing.png"
)
assert saved_payload["civitai"]["trainedWords"] == ["keep"] assert saved_payload["civitai"]["trainedWords"] == ["keep"]
assert saved_payload["civitai"]["id"] == 99 assert saved_payload["civitai"]["id"] == 99
assert saved_payload["legacy_field"] == "legacy" assert saved_payload["legacy_field"] == "legacy"
@@ -432,11 +528,22 @@ def test_download_model_invokes_download_manager(
assert call_args["download_id"] == payload["download_id"] assert call_args["download_id"] == payload["download_id"]
progress = ws_manager.get_download_progress(payload["download_id"]) progress = ws_manager.get_download_progress(payload["download_id"])
assert progress is not None assert progress is not None
expected_progress = round(download_manager_stub.last_progress_snapshot.percent_complete) expected_progress = round(
download_manager_stub.last_progress_snapshot.percent_complete
)
assert progress["progress"] == expected_progress assert progress["progress"] == expected_progress
assert progress["bytes_downloaded"] == download_manager_stub.last_progress_snapshot.bytes_downloaded assert (
assert progress["total_bytes"] == download_manager_stub.last_progress_snapshot.total_bytes progress["bytes_downloaded"]
assert progress["bytes_per_second"] == download_manager_stub.last_progress_snapshot.bytes_per_second == download_manager_stub.last_progress_snapshot.bytes_downloaded
)
assert (
progress["total_bytes"]
== download_manager_stub.last_progress_snapshot.total_bytes
)
assert (
progress["bytes_per_second"]
== download_manager_stub.last_progress_snapshot.bytes_per_second
)
assert "timestamp" in progress assert "timestamp" in progress
progress_response = await client.get( progress_response = await client.get(
@@ -526,21 +633,30 @@ def test_auto_organize_progress_returns_latest_snapshot(mock_service):
async def scenario(): async def scenario():
client = await create_test_client(mock_service) client = await create_test_client(mock_service)
try: try:
await ws_manager.broadcast_auto_organize_progress({"status": "processing", "percent": 50}) await ws_manager.broadcast_auto_organize_progress(
{"status": "processing", "percent": 50}
)
response = await client.get("/api/lm/test-models/auto-organize-progress") response = await client.get("/api/lm/test-models/auto-organize-progress")
payload = await response.json() payload = await response.json()
assert response.status == 200 assert response.status == 200
assert payload == {"success": True, "progress": {"status": "processing", "percent": 50}} assert payload == {
"success": True,
"progress": {"status": "processing", "percent": 50},
}
finally: finally:
await client.close() await client.close()
asyncio.run(scenario()) asyncio.run(scenario())
def test_auto_organize_route_emits_progress(mock_service, monkeypatch: pytest.MonkeyPatch): def test_auto_organize_route_emits_progress(
async def fake_auto_organize(self, file_paths=None, progress_callback=None, exclusion_patterns=None): mock_service, monkeypatch: pytest.MonkeyPatch
):
async def fake_auto_organize(
self, file_paths=None, progress_callback=None, exclusion_patterns=None
):
result = AutoOrganizeResult() result = AutoOrganizeResult()
result.total = 1 result.total = 1
result.processed = 1 result.processed = 1
@@ -549,8 +665,12 @@ def test_auto_organize_route_emits_progress(mock_service, monkeypatch: pytest.Mo
result.failure_count = 0 result.failure_count = 0
result.operation_type = "bulk" result.operation_type = "bulk"
if progress_callback is not None: if progress_callback is not None:
await progress_callback.on_progress({"type": "auto_organize_progress", "status": "started"}) await progress_callback.on_progress(
await progress_callback.on_progress({"type": "auto_organize_progress", "status": "completed"}) {"type": "auto_organize_progress", "status": "started"}
)
await progress_callback.on_progress(
{"type": "auto_organize_progress", "status": "completed"}
)
return result return result
monkeypatch.setattr( monkeypatch.setattr(
@@ -562,7 +682,9 @@ def test_auto_organize_route_emits_progress(mock_service, monkeypatch: pytest.Mo
async def scenario(): async def scenario():
client = await create_test_client(mock_service) client = await create_test_client(mock_service)
try: try:
response = await client.post("/api/lm/test-models/auto-organize", json={"file_paths": []}) response = await client.post(
"/api/lm/test-models/auto-organize", json={"file_paths": []}
)
payload = await response.json() payload = await response.json()
assert response.status == 200 assert response.status == 200

View File

@@ -1,4 +1,5 @@
"""Integration smoke tests for the recipe route stack.""" """Integration smoke tests for the recipe route stack."""
from __future__ import annotations from __future__ import annotations
import json import json
@@ -94,19 +95,25 @@ class StubAnalysisService:
self._recipe_parser_factory = None self._recipe_parser_factory = None
StubAnalysisService.instances.append(self) StubAnalysisService.instances.append(self)
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature async def analyze_uploaded_image(
self, *, image_bytes: bytes | None, recipe_scanner
) -> SimpleNamespace: # noqa: D401 - mirrors real signature
if self.raise_for_uploaded: if self.raise_for_uploaded:
raise self.raise_for_uploaded raise self.raise_for_uploaded
self.upload_calls.append(image_bytes or b"") self.upload_calls.append(image_bytes or b"")
return self.result return self.result
async def analyze_remote_image(self, *, url: Optional[str], recipe_scanner, civitai_client) -> SimpleNamespace: # noqa: D401 async def analyze_remote_image(
self, *, url: Optional[str], recipe_scanner, civitai_client
) -> SimpleNamespace: # noqa: D401
if self.raise_for_remote: if self.raise_for_remote:
raise self.raise_for_remote raise self.raise_for_remote
self.remote_calls.append(url) self.remote_calls.append(url)
return self.result return self.result
async def analyze_local_image(self, *, file_path: Optional[str], recipe_scanner) -> SimpleNamespace: # noqa: D401 async def analyze_local_image(
self, *, file_path: Optional[str], recipe_scanner
) -> SimpleNamespace: # noqa: D401
if self.raise_for_local: if self.raise_for_local:
raise self.raise_for_local raise self.raise_for_local
self.local_calls.append(file_path) self.local_calls.append(file_path)
@@ -125,11 +132,23 @@ class StubPersistenceService:
self.save_calls: List[Dict[str, Any]] = [] self.save_calls: List[Dict[str, Any]] = []
self.delete_calls: List[str] = [] self.delete_calls: List[str] = []
self.move_calls: List[Dict[str, str]] = [] self.move_calls: List[Dict[str, str]] = []
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200) self.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "stub-id"}, status=200
)
self.delete_result = SimpleNamespace(payload={"success": True}, status=200) self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
StubPersistenceService.instances.append(self) StubPersistenceService.instances.append(self)
async def save_recipe(self, *, recipe_scanner, image_bytes, image_base64, name, tags, metadata, extension=None) -> SimpleNamespace: # noqa: D401 async def save_recipe(
self,
*,
recipe_scanner,
image_bytes,
image_base64,
name,
tags,
metadata,
extension=None,
) -> SimpleNamespace: # noqa: D401
self.save_calls.append( self.save_calls.append(
{ {
"recipe_scanner": recipe_scanner, "recipe_scanner": recipe_scanner,
@@ -148,22 +167,42 @@ class StubPersistenceService:
await recipe_scanner.remove_recipe(recipe_id) await recipe_scanner.remove_recipe(recipe_id)
return self.delete_result return self.delete_result
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401 async def move_recipe(
self, *, recipe_scanner, recipe_id: str, target_path: str
) -> SimpleNamespace: # noqa: D401
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path}) self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
return SimpleNamespace( return SimpleNamespace(
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200 payload={
"success": True,
"recipe_id": recipe_id,
"new_file_path": target_path,
},
status=200,
) )
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests async def update_recipe(
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200) self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]
) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
return SimpleNamespace(
payload={"success": True, "recipe_id": recipe_id, "updates": updates},
status=200,
)
async def reconnect_lora(self, *, recipe_scanner, recipe_id: str, lora_index: int, target_name: str) -> SimpleNamespace: # pragma: no cover async def reconnect_lora(
self, *, recipe_scanner, recipe_id: str, lora_index: int, target_name: str
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200) return SimpleNamespace(payload={"success": True}, status=200)
async def bulk_delete(self, *, recipe_scanner, recipe_ids: List[str]) -> SimpleNamespace: # pragma: no cover async def bulk_delete(
return SimpleNamespace(payload={"success": True, "deleted": recipe_ids}, status=200) self, *, recipe_scanner, recipe_ids: List[str]
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(
payload={"success": True, "deleted": recipe_ids}, status=200
)
async def save_recipe_from_widget(self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes) -> SimpleNamespace: # pragma: no cover async def save_recipe_from_widget(
self, *, recipe_scanner, metadata: Dict[str, Any], image_bytes: bytes
) -> SimpleNamespace: # pragma: no cover
return SimpleNamespace(payload={"success": True}, status=200) return SimpleNamespace(payload={"success": True}, status=200)
@@ -176,7 +215,11 @@ class StubSharingService:
self.share_calls: List[str] = [] self.share_calls: List[str] = []
self.download_calls: List[str] = [] self.download_calls: List[str] = []
self.share_result = SimpleNamespace( self.share_result = SimpleNamespace(
payload={"success": True, "download_url": "/share/stub", "filename": "recipe.png"}, payload={
"success": True,
"download_url": "/share/stub",
"filename": "recipe.png",
},
status=200, status=200,
) )
self.download_info = SimpleNamespace(file_path="", download_filename="") self.download_info = SimpleNamespace(file_path="", download_filename="")
@@ -186,7 +229,9 @@ class StubSharingService:
self.share_calls.append(recipe_id) self.share_calls.append(recipe_id)
return self.share_result return self.share_result
async def prepare_download(self, *, recipe_scanner, recipe_id: str) -> SimpleNamespace: async def prepare_download(
self, *, recipe_scanner, recipe_id: str
) -> SimpleNamespace:
self.download_calls.append(recipe_id) self.download_calls.append(recipe_id)
return self.download_info return self.download_info
@@ -214,7 +259,9 @@ class StubCivitaiClient:
@asynccontextmanager @asynccontextmanager
async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRouteHarness]: async def recipe_harness(
monkeypatch, tmp_path: Path
) -> AsyncIterator[RecipeRouteHarness]:
"""Context manager that yields a fully wired recipe route harness.""" """Context manager that yields a fully wired recipe route harness."""
StubAnalysisService.instances.clear() StubAnalysisService.instances.clear()
@@ -237,8 +284,12 @@ async def recipe_harness(monkeypatch, tmp_path: Path) -> AsyncIterator[RecipeRou
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", fake_get_recipe_scanner) monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", fake_get_recipe_scanner)
monkeypatch.setattr(ServiceRegistry, "get_civitai_client", fake_get_civitai_client) monkeypatch.setattr(ServiceRegistry, "get_civitai_client", fake_get_civitai_client)
monkeypatch.setattr(base_recipe_routes, "RecipeAnalysisService", StubAnalysisService) monkeypatch.setattr(
monkeypatch.setattr(base_recipe_routes, "RecipePersistenceService", StubPersistenceService) base_recipe_routes, "RecipeAnalysisService", StubAnalysisService
)
monkeypatch.setattr(
base_recipe_routes, "RecipePersistenceService", StubPersistenceService
)
monkeypatch.setattr(base_recipe_routes, "RecipeSharingService", StubSharingService) monkeypatch.setattr(base_recipe_routes, "RecipeSharingService", StubSharingService)
monkeypatch.setattr(base_recipe_routes, "get_downloader", fake_get_downloader) monkeypatch.setattr(base_recipe_routes, "get_downloader", fake_get_downloader)
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)], raising=False) monkeypatch.setattr(config, "loras_roots", [str(tmp_path)], raising=False)
@@ -294,7 +345,9 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None: async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
form = FormData() form = FormData()
form.add_field("image", b"stub", filename="sample.png", content_type="image/png") form.add_field(
"image", b"stub", filename="sample.png", content_type="image/png"
)
form.add_field("name", "Test Recipe") form.add_field("name", "Test Recipe")
form.add_field("tags", json.dumps(["tag-a"])) form.add_field("tags", json.dumps(["tag-a"]))
form.add_field("metadata", json.dumps({"loras": []})) form.add_field("metadata", json.dumps({"loras": []}))
@@ -312,7 +365,9 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
assert save_payload["recipe_id"] == "saved-id" assert save_payload["recipe_id"] == "saved-id"
assert harness.persistence.save_calls[-1]["name"] == "Test Recipe" assert harness.persistence.save_calls[-1]["name"] == "Test Recipe"
harness.persistence.delete_result = SimpleNamespace(payload={"success": True}, status=200) harness.persistence.delete_result = SimpleNamespace(
payload={"success": True}, status=200
)
delete_response = await harness.client.delete("/api/lm/recipe/saved-id") delete_response = await harness.client.delete("/api/lm/recipe/saved-id")
delete_payload = await delete_response.json() delete_payload = await delete_response.json()
@@ -326,14 +381,20 @@ async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> N
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post( response = await harness.client.post(
"/api/lm/recipe/move", "/api/lm/recipe/move",
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}, json={
"recipe_id": "move-me",
"target_path": str(tmp_path / "recipes" / "subdir"),
},
) )
payload = await response.json() payload = await response.json()
assert response.status == 200 assert response.status == 200
assert payload["recipe_id"] == "move-me" assert payload["recipe_id"] == "move-me"
assert harness.persistence.move_calls == [ assert harness.persistence.move_calls == [
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")} {
"recipe_id": "move-me",
"target_path": str(tmp_path / "recipes" / "subdir"),
}
] ]
@@ -348,7 +409,10 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider(): async def fake_get_default_metadata_provider():
return Provider() return Provider()
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider) monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
resources = [ resources = [
@@ -397,7 +461,9 @@ async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
assert harness.downloader.urls == ["https://example.com/images/1"] assert harness.downloader.urls == ["https://example.com/images/1"]
async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch, tmp_path: Path) -> None: async def test_import_remote_recipe_falls_back_to_request_base_model(
monkeypatch, tmp_path: Path
) -> None:
provider_calls: list[str | int] = [] provider_calls: list[str | int] = []
class Provider: class Provider:
@@ -408,7 +474,10 @@ async def test_import_remote_recipe_falls_back_to_request_base_model(monkeypatch
async def fake_get_default_metadata_provider(): async def fake_get_default_metadata_provider():
return Provider() return Provider()
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider) monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
resources = [ resources = [
@@ -444,13 +513,16 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider(): async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None)) return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider) monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.civitai.image_info["12345"] = { harness.civitai.image_info["12345"] = {
"id": 12345, "id": 12345,
"url": "https://image.civitai.com/x/y/original=true/video.mp4", "url": "https://image.civitai.com/x/y/original=true/video.mp4",
"type": "video" "type": "video",
} }
response = await harness.client.get( response = await harness.client.get(
@@ -477,7 +549,9 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None: async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError("No image data provided") harness.analysis.raise_for_uploaded = RecipeValidationError(
"No image data provided"
)
form = FormData() form = FormData()
form.add_field("image", b"", filename="empty.png", content_type="image/png") form.add_field("image", b"", filename="empty.png", content_type="image/png")
@@ -504,7 +578,11 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
} }
harness.sharing.share_result = SimpleNamespace( harness.sharing.share_result = SimpleNamespace(
payload={"success": True, "download_url": "/api/share", "filename": "share.png"}, payload={
"success": True,
"download_url": "/api/share",
"filename": "share.png",
},
status=200, status=200,
) )
harness.sharing.download_info = SimpleNamespace( harness.sharing.download_info = SimpleNamespace(
@@ -519,15 +597,24 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
assert share_payload["filename"] == "share.png" assert share_payload["filename"] == "share.png"
assert harness.sharing.share_calls == [recipe_id] assert harness.sharing.share_calls == [recipe_id]
download_response = await harness.client.get(f"/api/lm/recipe/{recipe_id}/share/download") download_response = await harness.client.get(
f"/api/lm/recipe/{recipe_id}/share/download"
)
body = await download_response.read() body = await download_response.read()
assert download_response.status == 200 assert download_response.status == 200
assert download_response.headers["Content-Disposition"] == 'attachment; filename="share.png"' assert (
download_response.headers["Content-Disposition"]
== 'attachment; filename="share.png"'
)
assert body == b"stub" assert body == b"stub"
download_path.unlink(missing_ok=True) download_path.unlink(missing_ok=True)
async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path) -> None:
async def test_import_remote_recipe_merges_metadata(
monkeypatch, tmp_path: Path
) -> None:
# 1. Mock Metadata Provider # 1. Mock Metadata Provider
class Provider: class Provider:
async def get_model_version_info(self, model_version_id): async def get_model_version_info(self, model_version_id):
@@ -536,22 +623,25 @@ async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path)
async def fake_get_default_metadata_provider(): async def fake_get_default_metadata_provider():
return Provider() return Provider()
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", fake_get_default_metadata_provider) monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
# 2. Mock ExifUtils to return some embedded metadata # 2. Mock ExifUtils to return some embedded metadata
class MockExifUtils: class MockExifUtils:
@staticmethod @staticmethod
def extract_image_metadata(path): def extract_image_metadata(path):
return "Recipe metadata: " + json.dumps({ return "Recipe metadata: " + json.dumps(
"gen_params": {"prompt": "from embedded", "seed": 123} {"gen_params": {"prompt": "from embedded", "seed": 123}}
}) )
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils) monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
# 3. Mock Parser Factory for StubAnalysisService # 3. Mock Parser Factory for StubAnalysisService
class MockParser: class MockParser:
async def parse_metadata(self, raw, recipe_scanner=None): async def parse_metadata(self, raw, recipe_scanner=None):
return json.loads(raw[len("Recipe metadata: "):]) return json.loads(raw[len("Recipe metadata: ") :])
class MockFactory: class MockFactory:
def create_parser(self, raw): def create_parser(self, raw):
@@ -567,7 +657,7 @@ async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path)
harness.civitai.image_info["1"] = { harness.civitai.image_info["1"] = {
"id": 1, "id": 1,
"url": "https://example.com/images/1.jpg", "url": "https://example.com/images/1.jpg",
"meta": {"prompt": "from civitai", "cfg": 7.0} "meta": {"prompt": "from civitai", "cfg": 7.0},
} }
resources = [] resources = []
@@ -619,3 +709,142 @@ async def test_get_recipe_syntax(monkeypatch, tmp_path: Path) -> None:
response_404 = await harness.client.get("/api/lm/recipe/non-existent/syntax") response_404 = await harness.client.get("/api/lm/recipe/non-existent/syntax")
assert response_404.status == 404 assert response_404.status == 404
async def test_batch_import_start_success(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={
"items": [
{"source": "https://example.com/image1.png"},
{"source": "https://example.com/image2.png"},
],
"tags": ["batch", "import"],
"skip_no_metadata": True,
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert "operation_id" in payload
async def test_batch_import_start_empty_items(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [], "tags": []},
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert "No items provided" in payload["error"]
async def test_batch_import_start_missing_source(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [{"source": ""}]},
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert "source" in payload["error"].lower()
async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None:
import asyncio
async with recipe_harness(monkeypatch, tmp_path) as harness:
original_analyze = harness.analysis.analyze_remote_image
async def slow_analyze(*, url, recipe_scanner, civitai_client):
await asyncio.sleep(0.5)
return await original_analyze(
url=url, recipe_scanner=recipe_scanner, civitai_client=civitai_client
)
harness.analysis.analyze_remote_image = slow_analyze
items = [{"source": f"https://example.com/image{i}.png"} for i in range(10)]
response1 = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": items},
)
assert response1.status == 200
payload1 = await response1.json()
assert payload1["success"] is True
await asyncio.sleep(0.1)
response2 = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [{"source": "https://example.com/other.png"}]},
)
payload2 = await response2.json()
assert response2.status == 409
assert "already in progress" in payload2["error"].lower()
async def test_batch_import_get_progress_not_found(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get(
"/api/lm/recipes/batch-import/progress",
params={"operation_id": "nonexistent-id"},
)
payload = await response.json()
assert response.status == 404
assert payload["success"] is False
async def test_batch_import_get_progress_missing_id(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.get("/api/lm/recipes/batch-import/progress")
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
async def test_batch_import_cancel_success(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
start_response = await harness.client.post(
"/api/lm/recipes/batch-import/start",
json={"items": [{"source": "https://example.com/image.png"}]},
)
start_payload = await start_response.json()
operation_id = start_payload["operation_id"]
cancel_response = await harness.client.post(
"/api/lm/recipes/batch-import/cancel",
json={"operation_id": operation_id},
)
cancel_payload = await cancel_response.json()
assert cancel_response.status == 200
assert cancel_payload["success"] is True
async def test_batch_import_cancel_not_found(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/cancel",
json={"operation_id": "nonexistent-id"},
)
payload = await response.json()
assert response.status == 404
assert payload["success"] is False
async def test_batch_import_cancel_missing_id(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/batch-import/cancel",
json={},
)
payload = await response.json()
assert response.status == 400
assert payload["success"] is False

View File

@@ -0,0 +1,597 @@
"""Unit tests for BatchImportService."""
from __future__ import annotations
import asyncio
import logging
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from py.services.batch_import_service import (
AdaptiveConcurrencyController,
BatchImportItem,
BatchImportProgress,
BatchImportService,
ImportItemType,
ImportStatus,
)
class MockWebSocketManager:
def __init__(self):
self.broadcasts: List[Dict[str, Any]] = []
async def broadcast(self, data: Dict[str, Any]):
self.broadcasts.append(data)
@dataclass
class MockAnalysisResult:
payload: Dict[str, Any]
status: int = 200
class MockAnalysisService:
def __init__(self, results: Optional[Dict[str, MockAnalysisResult]] = None):
self.results = results or {}
self.call_count = 0
self.last_url = None
self.last_path = None
async def analyze_remote_image(self, *, url: str, recipe_scanner, civitai_client):
self.call_count += 1
self.last_url = url
if url in self.results:
return self.results[url]
return MockAnalysisResult({"error": "No metadata found", "loras": []})
async def analyze_local_image(self, *, file_path: str, recipe_scanner):
self.call_count += 1
self.last_path = file_path
if file_path in self.results:
return self.results[file_path]
return MockAnalysisResult({"error": "No metadata found", "loras": []})
@dataclass
class MockSaveResult:
payload: Dict[str, Any]
status: int = 200
class MockPersistenceService:
def __init__(self, should_succeed: bool = True):
self.should_succeed = should_succeed
self.saved_recipes: List[Dict[str, Any]] = []
self.call_count = 0
async def save_recipe(
self,
*,
recipe_scanner,
image_bytes: Optional[bytes] = None,
image_base64: Optional[str] = None,
name: str,
tags: List[str],
metadata: Dict[str, Any],
extension: Optional[str] = None,
):
self.call_count += 1
self.saved_recipes.append(
{
"name": name,
"tags": tags,
"metadata": metadata,
}
)
if self.should_succeed:
return MockSaveResult({"success": True, "id": f"recipe_{self.call_count}"})
return MockSaveResult({"success": False, "error": "Save failed"}, status=400)
class TestAdaptiveConcurrencyController:
def test_initial_values(self):
controller = AdaptiveConcurrencyController()
assert controller.current_concurrency == 3
assert controller.min_concurrency == 1
assert controller.max_concurrency == 5
def test_custom_initial_values(self):
controller = AdaptiveConcurrencyController(
min_concurrency=2,
max_concurrency=10,
initial_concurrency=5,
)
assert controller.current_concurrency == 5
assert controller.min_concurrency == 2
assert controller.max_concurrency == 10
def test_increase_concurrency_on_success(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=0.5, success=True)
assert controller.current_concurrency == 4
def test_do_not_exceed_max(self):
controller = AdaptiveConcurrencyController(
max_concurrency=5,
initial_concurrency=5,
)
controller.record_result(duration=0.5, success=True)
assert controller.current_concurrency == 5
def test_decrease_concurrency_on_failure(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=1.0, success=False)
assert controller.current_concurrency == 2
def test_do_not_go_below_min(self):
controller = AdaptiveConcurrencyController(
min_concurrency=1,
initial_concurrency=1,
)
controller.record_result(duration=1.0, success=False)
assert controller.current_concurrency == 1
def test_slow_task_decreases_concurrency(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=11.0, success=True)
assert controller.current_concurrency == 2
def test_fast_task_increases_concurrency(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=0.5, success=True)
assert controller.current_concurrency == 4
def test_moderate_task_no_change(self):
controller = AdaptiveConcurrencyController(initial_concurrency=3)
controller.record_result(duration=5.0, success=True)
assert controller.current_concurrency == 3
class TestBatchImportProgress:
def test_to_dict(self):
progress = BatchImportProgress(
operation_id="test-123",
total=10,
completed=5,
success=3,
failed=2,
skipped=0,
current_item="image.png",
status="running",
)
result = progress.to_dict()
assert result["operation_id"] == "test-123"
assert result["total"] == 10
assert result["completed"] == 5
assert result["success"] == 3
assert result["failed"] == 2
assert result["progress_percent"] == 50.0
def test_progress_percent_zero_total(self):
progress = BatchImportProgress(
operation_id="test-123",
total=0,
)
assert progress.to_dict()["progress_percent"] == 0
class TestBatchImportItem:
def test_defaults(self):
item = BatchImportItem(
id="item-1",
source="https://example.com/image.png",
item_type=ImportItemType.URL,
)
assert item.status == ImportStatus.PENDING
assert item.error_message is None
assert item.recipe_name is None
class TestBatchImportService:
@pytest.fixture
def mock_services(self):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService()
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
return ws_manager, analysis_service, persistence_service, logger
@pytest.fixture
def service(self, mock_services):
ws_manager, analysis_service, persistence_service, logger = mock_services
return BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
def test_is_import_running_no_operations(self, service):
assert not service.is_import_running()
@pytest.mark.asyncio
async def test_start_batch_import_creates_operation(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/image.png"}],
)
assert operation_id is not None
assert service.is_import_running(operation_id)
@pytest.mark.asyncio
async def test_get_progress(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[
{"source": "https://example.com/1.png"},
{"source": "https://example.com/2.png"},
],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 2
assert progress.status in ("pending", "running")
@pytest.mark.asyncio
async def test_cancel_import(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/image.png"}],
)
assert service.cancel_import(operation_id) is True
assert service.cancel_import("nonexistent") is False
@pytest.mark.asyncio
async def test_discover_images_non_recursive(self, service, tmp_path):
for i in range(3):
(tmp_path / f"image{i}.png").write_bytes(b"fake-image")
(tmp_path / "subdir").mkdir()
(tmp_path / "subdir" / "hidden.png").write_bytes(b"fake-image")
images = await service._discover_images(str(tmp_path), recursive=False)
assert len(images) == 3
@pytest.mark.asyncio
async def test_discover_images_recursive(self, service, tmp_path):
for i in range(2):
(tmp_path / f"image{i}.png").write_bytes(b"fake-image")
subdir = tmp_path / "subdir"
subdir.mkdir()
for i in range(2):
(subdir / f"nested{i}.jpg").write_bytes(b"fake-image")
images = await service._discover_images(str(tmp_path), recursive=True)
assert len(images) == 4
@pytest.mark.asyncio
async def test_discover_images_filters_by_extension(self, service, tmp_path):
(tmp_path / "image.png").write_bytes(b"fake-image")
(tmp_path / "image.jpg").write_bytes(b"fake-image")
(tmp_path / "image.webp").write_bytes(b"fake-image")
(tmp_path / "document.pdf").write_bytes(b"fake-doc")
(tmp_path / "script.py").write_bytes(b"print('hello')")
images = await service._discover_images(str(tmp_path), recursive=False)
assert len(images) == 3
@pytest.mark.asyncio
async def test_discover_images_invalid_directory(self, service):
from py.services.recipes.errors import RecipeValidationError
with pytest.raises(RecipeValidationError):
await service._discover_images("/nonexistent/path", recursive=False)
def test_is_supported_image(self, service):
assert service._is_supported_image("test.png") is True
assert service._is_supported_image("test.jpg") is True
assert service._is_supported_image("test.jpeg") is True
assert service._is_supported_image("test.webp") is True
assert service._is_supported_image("test.gif") is True
assert service._is_supported_image("test.bmp") is True
assert service._is_supported_image("test.pdf") is False
assert service._is_supported_image("test.txt") is False
@pytest.mark.asyncio
async def test_batch_import_processes_items(self, mock_services, tmp_path):
ws_manager, _, persistence_service, logger = mock_services
analysis_service = MockAnalysisService(
{
"https://example.com/valid.png": MockAnalysisResult(
{
"loras": [{"name": "test-lora", "weight": 1.0}],
"base_model": "SD1.5",
"gen_params": {"steps": 20},
}
),
}
)
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace(
find_recipes_by_fingerprint=lambda x: [],
add_recipe=lambda x: None,
)
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[
{"source": "https://example.com/valid.png"},
{"source": "https://example.com/no-meta.png"},
],
skip_no_metadata=True,
)
await asyncio.sleep(0.5)
progress = service.get_progress(operation_id)
assert progress is not None or persistence_service.call_count == 1
@pytest.mark.asyncio
async def test_start_directory_import(self, service, tmp_path):
for i in range(5):
(tmp_path / f"image{i}.png").write_bytes(b"fake-image")
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_directory_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
directory=str(tmp_path),
recursive=False,
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 5
@pytest.mark.asyncio
async def test_websocket_broadcasts_progress(self, mock_services):
ws_manager, analysis_service, persistence_service, logger = mock_services
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/test.png"}],
)
await asyncio.sleep(0.3)
assert len(ws_manager.broadcasts) > 0
assert any(
b.get("type") == "batch_import_progress" for b in ws_manager.broadcasts
)
@pytest.mark.asyncio
async def test_cancellation_stops_processing(self, mock_services):
ws_manager, analysis_service, persistence_service, logger = mock_services
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
items = [{"source": f"https://example.com/{i}.png"} for i in range(10)]
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=items,
)
service.cancel_import(operation_id)
await asyncio.sleep(0.3)
progress = service.get_progress(operation_id)
if progress:
assert progress.status == "cancelled"
class TestBatchImportServiceEdgeCases:
@pytest.fixture
def service(self):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService()
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
return BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
@pytest.mark.asyncio
async def test_empty_items_list(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 0
@pytest.mark.asyncio
async def test_mixed_url_and_path_items(self, service, tmp_path):
(tmp_path / "local.png").write_bytes(b"fake-image")
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[
{"source": "https://example.com/remote.png", "type": "url"},
{"source": str(tmp_path / "local.png"), "type": "local_path"},
],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.total == 2
assert progress.items[0].item_type == ImportItemType.URL
assert progress.items[1].item_type == ImportItemType.LOCAL_PATH
@pytest.mark.asyncio
async def test_tags_are_passed_to_persistence(self, tmp_path):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService(
{
str(tmp_path / "test.png"): MockAnalysisResult(
{
"loras": [{"name": "test-lora"}],
}
),
}
)
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
(tmp_path / "test.png").write_bytes(b"fake-image")
service = BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
recipe_scanner_getter = lambda: SimpleNamespace(
find_recipes_by_fingerprint=lambda x: [],
)
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": str(tmp_path / "test.png")}],
tags=["batch-import", "test"],
)
await asyncio.sleep(0.3)
if persistence_service.saved_recipes:
assert "batch-import" in persistence_service.saved_recipes[0]["tags"]
assert "test" in persistence_service.saved_recipes[0]["tags"]
@pytest.mark.asyncio
async def test_skip_duplicates_parameter(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/test.png"}],
skip_duplicates=True,
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.skip_duplicates is True
@pytest.mark.asyncio
async def test_skip_duplicates_false_by_default(self, service):
recipe_scanner_getter = lambda: SimpleNamespace()
civitai_client_getter = lambda: SimpleNamespace()
operation_id = await service.start_batch_import(
recipe_scanner_getter=recipe_scanner_getter,
civitai_client_getter=civitai_client_getter,
items=[{"source": "https://example.com/test.png"}],
)
progress = service.get_progress(operation_id)
assert progress is not None
assert progress.skip_duplicates is False
class TestInputValidation:
@pytest.fixture
def service(self):
ws_manager = MockWebSocketManager()
analysis_service = MockAnalysisService()
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
return BatchImportService(
analysis_service=analysis_service,
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
def test_validate_valid_url(self, service):
assert service._validate_url("https://example.com/image.png") is True
assert service._validate_url("http://example.com/image.png") is True
assert service._validate_url("https://civitai.com/images/123") is True
def test_validate_invalid_url(self, service):
assert service._validate_url("not-a-url") is False
assert service._validate_url("ftp://example.com/file") is False
assert service._validate_url("") is False
def test_validate_valid_local_path(self, service, tmp_path):
valid_path = str(tmp_path / "image.png")
assert service._validate_local_path(valid_path) is True
def test_validate_invalid_local_path(self, service):
assert service._validate_local_path("../etc/passwd") is False
assert service._validate_local_path("relative/path.png") is False
assert service._validate_local_path("") is False

View File

@@ -73,6 +73,46 @@ class TestCacheEntryValidator:
assert result.repaired is False assert result.repaired is False
assert any('sha256' in error for error in result.errors) assert any('sha256' in error for error in result.errors)
def test_validate_empty_sha256_allowed_when_hash_status_pending(self):
"""Test validation passes when sha256 is empty but hash_status is pending (lazy hash)"""
entry = {
'file_path': '/models/test.safetensors',
'sha256': '', # Empty string
'hash_status': 'pending', # Lazy hash calculation
}
result = CacheEntryValidator.validate(entry, auto_repair=False)
assert result.is_valid is True
assert result.entry['sha256'] == ''
assert result.entry['hash_status'] == 'pending'
def test_validate_empty_sha256_fails_when_hash_status_not_pending(self):
"""Test validation fails when sha256 is empty and hash_status is not pending"""
entry = {
'file_path': '/models/test.safetensors',
'sha256': '', # Empty string
'hash_status': 'completed', # Not pending
}
result = CacheEntryValidator.validate(entry, auto_repair=False)
assert result.is_valid is False
assert any('sha256' in error for error in result.errors)
def test_validate_empty_sha256_fails_when_hash_status_missing(self):
"""Test validation fails when sha256 is empty and hash_status is missing"""
entry = {
'file_path': '/models/test.safetensors',
'sha256': '', # Empty string
# hash_status missing (defaults to 'completed')
}
result = CacheEntryValidator.validate(entry, auto_repair=False)
assert result.is_valid is False
assert any('sha256' in error for error in result.errors)
def test_validate_empty_required_field_file_path(self): def test_validate_empty_required_field_file_path(self):
"""Test validation fails when file_path is empty string""" """Test validation fails when file_path is empty string"""
entry = { entry = {

View File

@@ -60,11 +60,13 @@ class StubLoraScanner:
"preview_url": info.get("preview_url", ""), "preview_url": info.get("preview_url", ""),
"civitai": info.get("civitai", {}), "civitai": info.get("civitai", {}),
} }
self._cache.raw_data.append({ self._cache.raw_data.append(
"sha256": info.get("sha256", ""), {
"path": info.get("file_path", ""), "sha256": info.get("sha256", ""),
"civitai": info.get("civitai", {}), "path": info.get("file_path", ""),
}) "civitai": info.get("civitai", {}),
}
)
@pytest.fixture @pytest.fixture
@@ -107,7 +109,8 @@ async def test_add_recipe_during_concurrent_reads(recipe_scanner):
await asyncio.sleep(0) await asyncio.sleep(0)
await asyncio.gather(reader_task(), reader_task(), scanner.add_recipe(new_recipe)) await asyncio.gather(reader_task(), reader_task(), scanner.add_recipe(new_recipe))
await asyncio.sleep(0) # Wait a bit longer for the thread-pool resort to complete
await asyncio.sleep(0.1)
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
assert {item["id"] for item in cache.raw_data} == {"one", "two"} assert {item["id"] for item in cache.raw_data} == {"one", "two"}
@@ -119,14 +122,16 @@ async def test_remove_recipe_during_reads(recipe_scanner):
recipe_ids = ["alpha", "beta", "gamma"] recipe_ids = ["alpha", "beta", "gamma"]
for index, recipe_id in enumerate(recipe_ids): for index, recipe_id in enumerate(recipe_ids):
await scanner.add_recipe({ await scanner.add_recipe(
"id": recipe_id, {
"file_path": f"path/{recipe_id}.png", "id": recipe_id,
"title": recipe_id, "file_path": f"path/{recipe_id}.png",
"modified": float(index), "title": recipe_id,
"created_date": float(index), "modified": float(index),
"loras": [], "created_date": float(index),
}) "loras": [],
}
)
async def reader_task(): async def reader_task():
for _ in range(5): for _ in range(5):
@@ -155,7 +160,13 @@ async def test_update_lora_entry_updates_cache_and_file(tmp_path: Path, recipe_s
"modified": 0.0, "modified": 0.0,
"created_date": 0.0, "created_date": 0.0,
"loras": [ "loras": [
{"file_name": "old", "strength": 1.0, "hash": "", "isDeleted": True, "exclude": True}, {
"file_name": "old",
"strength": 1.0,
"hash": "",
"isDeleted": True,
"exclude": True,
},
], ],
} }
recipe_path.write_text(json.dumps(recipe_data)) recipe_path.write_text(json.dumps(recipe_data))
@@ -380,7 +391,9 @@ async def test_initialize_waits_for_lora_scanner(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_model_version_marked_deleted_and_not_retried(monkeypatch, recipe_scanner): async def test_invalid_model_version_marked_deleted_and_not_retried(
monkeypatch, recipe_scanner
):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes" recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True) recipes_dir.mkdir(parents=True, exist_ok=True)
@@ -417,7 +430,9 @@ async def test_invalid_model_version_marked_deleted_and_not_retried(monkeypatch,
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_load_recipe_persists_deleted_flag_on_invalid_version(monkeypatch, recipe_scanner, tmp_path): async def test_load_recipe_persists_deleted_flag_on_invalid_version(
monkeypatch, recipe_scanner, tmp_path
):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes" recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True) recipes_dir.mkdir(parents=True, exist_ok=True)
@@ -448,7 +463,9 @@ async def test_load_recipe_persists_deleted_flag_on_invalid_version(monkeypatch,
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: Path, recipe_scanner): async def test_update_lora_filename_by_hash_updates_affected_recipes(
tmp_path: Path, recipe_scanner
):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes" recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True) recipes_dir.mkdir(parents=True, exist_ok=True)
@@ -464,7 +481,7 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
"created_date": 0.0, "created_date": 0.0,
"loras": [ "loras": [
{"file_name": "old_name", "hash": "hash1"}, {"file_name": "old_name", "hash": "hash1"},
{"file_name": "other_lora", "hash": "hash2"} {"file_name": "other_lora", "hash": "hash2"},
], ],
} }
recipe1_path.write_text(json.dumps(recipe1_data)) recipe1_path.write_text(json.dumps(recipe1_data))
@@ -479,16 +496,16 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
"title": "Recipe 2", "title": "Recipe 2",
"modified": 0.0, "modified": 0.0,
"created_date": 0.0, "created_date": 0.0,
"loras": [ "loras": [{"file_name": "other_lora", "hash": "hash2"}],
{"file_name": "other_lora", "hash": "hash2"}
],
} }
recipe2_path.write_text(json.dumps(recipe2_data)) recipe2_path.write_text(json.dumps(recipe2_data))
await scanner.add_recipe(dict(recipe2_data)) await scanner.add_recipe(dict(recipe2_data))
# Update LoRA name for "hash1" (using different case to test normalization) # Update LoRA name for "hash1" (using different case to test normalization)
new_name = "new_name" new_name = "new_name"
file_count, cache_count = await scanner.update_lora_filename_by_hash("HASH1", new_name) file_count, cache_count = await scanner.update_lora_filename_by_hash(
"HASH1", new_name
)
assert file_count == 1 assert file_count == 1
assert cache_count == 1 assert cache_count == 1
@@ -512,25 +529,29 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
# Add a normal recipe # Add a normal recipe
await scanner.add_recipe({ await scanner.add_recipe(
"id": "regular", {
"file_path": "path/regular.png", "id": "regular",
"title": "Regular Recipe", "file_path": "path/regular.png",
"modified": 1.0, "title": "Regular Recipe",
"created_date": 1.0, "modified": 1.0,
"loras": [], "created_date": 1.0,
}) "loras": [],
}
)
# Add a favorite recipe # Add a favorite recipe
await scanner.add_recipe({ await scanner.add_recipe(
"id": "favorite", {
"file_path": "path/favorite.png", "id": "favorite",
"title": "Favorite Recipe", "file_path": "path/favorite.png",
"modified": 2.0, "title": "Favorite Recipe",
"created_date": 2.0, "modified": 2.0,
"loras": [], "created_date": 2.0,
"favorite": True "loras": [],
}) "favorite": True,
}
)
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe) # Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -540,13 +561,17 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
assert len(result_all["items"]) == 2 assert len(result_all["items"]) == 2
# Test with favorite filter # Test with favorite filter
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True}) result_fav = await scanner.get_paginated_data(
page=1, page_size=10, filters={"favorite": True}
)
assert len(result_fav["items"]) == 1 assert len(result_fav["items"]) == 1
assert result_fav["items"][0]["id"] == "favorite" assert result_fav["items"][0]["id"] == "favorite"
# Test with favorite filter set to False (should return both or at least not filter if it's the default) # Test with favorite filter set to False (should return both or at least not filter if it's the default)
# Actually our implementation checks if 'favorite' in filters and filters['favorite'] # Actually our implementation checks if 'favorite' in filters and filters['favorite']
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False}) result_fav_false = await scanner.get_paginated_data(
page=1, page_size=10, filters={"favorite": False}
)
assert len(result_fav_false["items"]) == 2 assert len(result_fav_false["items"]) == 2
@@ -555,30 +580,30 @@ async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
# Add a recipe with a specific prompt # Add a recipe with a specific prompt
await scanner.add_recipe({ await scanner.add_recipe(
"id": "prompt-recipe", {
"file_path": "path/prompt.png", "id": "prompt-recipe",
"title": "Prompt Recipe", "file_path": "path/prompt.png",
"modified": 1.0, "title": "Prompt Recipe",
"created_date": 1.0, "modified": 1.0,
"loras": [], "created_date": 1.0,
"gen_params": { "loras": [],
"prompt": "a beautiful forest landscape" "gen_params": {"prompt": "a beautiful forest landscape"},
} }
}) )
# Add a recipe with a specific negative prompt # Add a recipe with a specific negative prompt
await scanner.add_recipe({ await scanner.add_recipe(
"id": "neg-prompt-recipe", {
"file_path": "path/neg.png", "id": "neg-prompt-recipe",
"title": "Negative Prompt Recipe", "file_path": "path/neg.png",
"modified": 2.0, "title": "Negative Prompt Recipe",
"created_date": 2.0, "modified": 2.0,
"loras": [], "created_date": 2.0,
"gen_params": { "loras": [],
"negative_prompt": "ugly, blurry mountains" "gen_params": {"negative_prompt": "ugly, blurry mountains"},
} }
}) )
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -609,20 +634,35 @@ async def test_get_paginated_data_sorting(recipe_scanner):
# Add test recipes # Add test recipes
# Recipe A: Name "Alpha", Date 10, LoRAs 2 # Recipe A: Name "Alpha", Date 10, LoRAs 2
await scanner.add_recipe({ await scanner.add_recipe(
"id": "A", "title": "Alpha", "created_date": 10.0, {
"loras": [{}, {}], "file_path": "a.png" "id": "A",
}) "title": "Alpha",
"created_date": 10.0,
"loras": [{}, {}],
"file_path": "a.png",
}
)
# Recipe B: Name "Beta", Date 20, LoRAs 1 # Recipe B: Name "Beta", Date 20, LoRAs 1
await scanner.add_recipe({ await scanner.add_recipe(
"id": "B", "title": "Beta", "created_date": 20.0, {
"loras": [{}], "file_path": "b.png" "id": "B",
}) "title": "Beta",
"created_date": 20.0,
"loras": [{}],
"file_path": "b.png",
}
)
# Recipe C: Name "Gamma", Date 5, LoRAs 3 # Recipe C: Name "Gamma", Date 5, LoRAs 3
await scanner.add_recipe({ await scanner.add_recipe(
"id": "C", "title": "Gamma", "created_date": 5.0, {
"loras": [{}, {}, {}], "file_path": "c.png" "id": "C",
}) "title": "Gamma",
"created_date": 5.0,
"loras": [{}, {}, {}],
"file_path": "c.png",
}
)
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -631,11 +671,15 @@ async def test_get_paginated_data_sorting(recipe_scanner):
assert [i["id"] for i in res["items"]] == ["C", "B", "A"] assert [i["id"] for i in res["items"]] == ["C", "B", "A"]
# Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1) # Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc") res = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="loras_count:desc"
)
assert [i["id"] for i in res["items"]] == ["C", "A", "B"] assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
# Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3) # Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc") res = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="loras_count:asc"
)
assert [i["id"] for i in res["items"]] == ["B", "A", "C"] assert [i["id"] for i in res["items"]] == ["B", "A", "C"]
# Test Date ASC: Gamma (5), Alpha (10), Beta (20) # Test Date ASC: Gamma (5), Alpha (10), Beta (20)

View File

@@ -62,3 +62,42 @@ async def test_search_relative_paths_excludes_tokens():
matching = await service.search_relative_paths("flux -detail") matching = await service.search_relative_paths("flux -detail")
assert matching == [f"flux{os.sep}keep-me.safetensors"] assert matching == [f"flux{os.sep}keep-me.safetensors"]
@pytest.mark.asyncio
async def test_search_does_not_match_extension():
"""Searching for 's' or 'safe' should not match .safetensors extension."""
scanner = FakeScanner(
[
{"file_path": "/models/lora1.safetensors"},
{"file_path": "/models/lora2.safetensors"},
{"file_path": "/models/special-model.safetensors"}, # 's' in filename
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
# Searching for 's' should only match 'special-model', not all .safetensors
matching = await service.search_relative_paths("s")
# Should only match 'special-model' because 's' is in the filename
assert len(matching) == 1
assert "special-model" in matching[0]
@pytest.mark.asyncio
async def test_search_safe_does_not_match_all_files():
"""Searching for 'safe' should not match .safetensors extension."""
scanner = FakeScanner(
[
{"file_path": "/models/flux.safetensors"},
{"file_path": "/models/detail.safetensors"},
],
["/models"],
)
service = DummyService("stub", scanner, BaseModelMetadata)
# Searching for 'safe' should return nothing (no file has 'safe' in its name)
matching = await service.search_relative_paths("safe")
assert len(matching) == 0

View File

@@ -0,0 +1,404 @@
"""Tests for BulkMetadataRefreshUseCase."""
from __future__ import annotations
import pytest
from types import SimpleNamespace
from typing import Any, Dict
from unittest.mock import AsyncMock, MagicMock, patch
from py.services.use_cases.bulk_metadata_refresh_use_case import (
BulkMetadataRefreshUseCase,
MetadataRefreshProgressReporter,
)
from py.utils import metadata_manager
class MockProgressReporter:
"""Mock progress reporter for testing."""
def __init__(self):
self.progress_calls = []
async def on_progress(self, payload: Dict[str, Any]) -> None:
self.progress_calls.append(payload)
@pytest.fixture
def mock_service():
"""Create a mock service with scanner."""
scanner = MagicMock()
scanner.get_cached_data = AsyncMock()
scanner.reset_cancellation = MagicMock()
scanner.is_cancelled = MagicMock(return_value=False)
scanner.update_single_model_cache = AsyncMock(return_value=True)
scanner.calculate_hash_for_model = AsyncMock(return_value="calculated_hash_123")
service = MagicMock()
service.scanner = scanner
service.model_type = "checkpoint"
return service
@pytest.fixture
def mock_metadata_sync():
"""Create a mock metadata sync service."""
sync = MagicMock()
sync.fetch_and_update_model = AsyncMock(return_value=(True, None))
return sync
@pytest.fixture
def mock_settings():
"""Create mock settings service."""
settings = MagicMock()
settings.get = MagicMock(return_value=False)
return settings
@pytest.fixture
def use_case(mock_service, mock_metadata_sync, mock_settings):
"""Create a BulkMetadataRefreshUseCase instance."""
return BulkMetadataRefreshUseCase(
service=mock_service,
metadata_sync=mock_metadata_sync,
settings_service=mock_settings,
)
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_pending_hash_calculated_on_demand(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Test that models with pending hash status get their hash calculated on demand."""
mock_hydrate.return_value = None
# Setup cache with a model that has pending hash
pending_model = {
"file_path": "/extra_ckpt/model.safetensors",
"sha256": "", # Empty hash
"hash_status": "pending",
"model_name": "Test Model",
"folder": "extra_ckpt",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[pending_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify hash was calculated
mock_service.scanner.calculate_hash_for_model.assert_called_once_with(
"/extra_ckpt/model.safetensors"
)
# Verify model hash was updated
assert pending_model["sha256"] == "calculated_hash_123"
assert pending_model["hash_status"] == "completed"
# Verify metadata sync was called with the calculated hash
mock_metadata_sync.fetch_and_update_model.assert_called_once()
call_args = mock_metadata_sync.fetch_and_update_model.call_args[1]
assert call_args["sha256"] == "calculated_hash_123"
assert result["success"] is True
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_skip_model_when_hash_calculation_fails(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Test that models are skipped when hash calculation fails."""
mock_hydrate.return_value = None
# Setup model with pending hash
pending_model = {
"file_path": "/extra_ckpt/model.safetensors",
"sha256": "",
"hash_status": "pending",
"model_name": "Test Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
# Make hash calculation fail
mock_service.scanner.calculate_hash_for_model.return_value = None
cache = SimpleNamespace(raw_data=[pending_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify hash was attempted
mock_service.scanner.calculate_hash_for_model.assert_called_once()
# Verify metadata sync was NOT called (model skipped)
mock_metadata_sync.fetch_and_update_model.assert_not_called()
# Verify result shows processed but no success
assert result["success"] is True
assert result["processed"] == 1
assert result["updated"] == 0
@pytest.mark.asyncio
async def test_skip_model_when_scanner_does_not_support_lazy_hash(
use_case, mock_service, mock_metadata_sync
):
"""Test that models are skipped when scanner doesn't support lazy hash calculation."""
# Setup model with pending hash
pending_model = {
"file_path": "/models/model.safetensors",
"sha256": "",
"hash_status": "pending",
"model_name": "Test Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
# Remove calculate_hash_for_model method (simulating LoRA scanner)
del mock_service.scanner.calculate_hash_for_model
cache = SimpleNamespace(raw_data=[pending_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify metadata sync was NOT called
mock_metadata_sync.fetch_and_update_model.assert_not_called()
assert result["success"] is True
assert result["processed"] == 1
assert result["updated"] == 0
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_normal_model_with_existing_hash_not_affected(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Test that models with existing hash work normally."""
mock_hydrate.return_value = None
# Setup model with existing hash
existing_model = {
"file_path": "/models/model.safetensors",
"sha256": "existing_hash_abc",
"hash_status": "completed",
"model_name": "Test Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[existing_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify hash calculation was NOT called
assert not mock_service.scanner.calculate_hash_for_model.called
# Verify metadata sync was called with existing hash
mock_metadata_sync.fetch_and_update_model.assert_called_once()
call_args = mock_metadata_sync.fetch_and_update_model.call_args[1]
assert call_args["sha256"] == "existing_hash_abc"
assert result["success"] is True
assert result["updated"] == 1
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_mixed_models_some_pending_some_existing(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Test handling of mixed models: some with pending hash, some with existing hash."""
mock_hydrate.return_value = None
pending_model = {
"file_path": "/extra_ckpt/pending_model.safetensors",
"sha256": "",
"hash_status": "pending",
"model_name": "Pending Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
existing_model = {
"file_path": "/models/existing_model.safetensors",
"sha256": "existing_hash_xyz",
"hash_status": "completed",
"model_name": "Existing Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[pending_model, existing_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify hash was calculated only for pending model
mock_service.scanner.calculate_hash_for_model.assert_called_once_with(
"/extra_ckpt/pending_model.safetensors"
)
# Verify metadata sync was called for both
assert mock_metadata_sync.fetch_and_update_model.call_count == 2
assert result["success"] is True
assert result["processed"] == 2
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_progress_callback_receives_updates(mock_hydrate, use_case, mock_service):
"""Test that progress callback receives correct updates."""
mock_hydrate.return_value = None
model = {
"file_path": "/models/model.safetensors",
"sha256": "hash123",
"model_name": "Test Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
reporter = MockProgressReporter()
# Execute
await use_case.execute(progress_callback=reporter)
# Verify progress was reported
assert len(reporter.progress_calls) >= 2
# Check started status
started_calls = [c for c in reporter.progress_calls if c["status"] == "started"]
assert len(started_calls) == 1
# Check completed status
completed_calls = [c for c in reporter.progress_calls if c["status"] == "completed"]
assert len(completed_calls) == 1
assert completed_calls[0]["processed"] == 1
assert completed_calls[0]["success"] == 1
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_respects_skip_metadata_refresh_flag(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Test that models with skip_metadata_refresh=True are skipped."""
mock_hydrate.return_value = None
skip_model = {
"file_path": "/models/skip_model.safetensors",
"sha256": "hash123",
"model_name": "Skip Model",
"skip_metadata_refresh": True,
"civitai": {},
}
normal_model = {
"file_path": "/models/normal_model.safetensors",
"sha256": "hash456",
"model_name": "Normal Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[skip_model, normal_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify only normal model was processed
assert mock_metadata_sync.fetch_and_update_model.call_count == 1
call_args = mock_metadata_sync.fetch_and_update_model.call_args[1]
assert call_args["file_path"] == "/models/normal_model.safetensors"
assert result["processed"] == 1
@pytest.mark.asyncio
@patch.object(metadata_manager.MetadataManager, "hydrate_model_data")
async def test_respects_skip_paths(mock_hydrate, use_case, mock_service, mock_metadata_sync):
"""Test that models in skip paths are excluded."""
mock_hydrate.return_value = None
# Setup settings to skip certain paths
use_case._settings.get = MagicMock(side_effect=lambda key, default=None: {
"enable_metadata_archive_db": False,
"metadata_refresh_skip_paths": ["skip_folder"],
}.get(key, default))
skip_path_model = {
"file_path": "/models/skip_folder/model.safetensors",
"sha256": "hash123",
"model_name": "Skip Path Model",
"folder": "skip_folder",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
normal_model = {
"file_path": "/models/normal/model.safetensors",
"sha256": "hash456",
"model_name": "Normal Model",
"folder": "normal",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[skip_path_model, normal_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify only normal model was processed
assert mock_metadata_sync.fetch_and_update_model.call_count == 1
call_args = mock_metadata_sync.fetch_and_update_model.call_args[1]
assert "normal" in call_args["file_path"]
assert result["processed"] == 1
@pytest.mark.asyncio
async def test_model_without_hash_skipped(use_case, mock_service, mock_metadata_sync):
"""Test that models without hash (and not pending) are skipped."""
no_hash_model = {
"file_path": "/models/no_hash_model.safetensors",
"sha256": "", # Empty but NOT pending
"hash_status": "completed", # Not pending
"model_name": "No Hash Model",
"civitai": {},
"from_civitai": False,
"civitai_deleted": False,
}
cache = SimpleNamespace(raw_data=[no_hash_model], resort=AsyncMock())
mock_service.scanner.get_cached_data.return_value = cache
# Execute
result = await use_case.execute()
# Verify metadata sync was NOT called
mock_metadata_sync.fetch_and_update_model.assert_not_called()
assert result["processed"] == 1
assert result["updated"] == 0

View File

@@ -2,7 +2,10 @@
import pytest import pytest
from py.services.custom_words_service import CustomWordsService, get_custom_words_service from py.services.custom_words_service import (
CustomWordsService,
get_custom_words_service,
)
class TestCustomWordsService: class TestCustomWordsService:
@@ -99,13 +102,19 @@ class MockTagFTSIndex:
self.called = False self.called = False
self._results = [ self._results = [
{"tag_name": "hatsune_miku", "category": 4, "post_count": 500000}, {"tag_name": "hatsune_miku", "category": 4, "post_count": 500000},
{"tag_name": "hatsune_miku_(vocaloid)", "category": 4, "post_count": 250000}, {
"tag_name": "hatsune_miku_(vocaloid)",
"category": 4,
"post_count": 250000,
},
] ]
def search(self, query, categories=None, limit=20): def search(self, query, categories=None, limit=20, offset=0):
self.called = True self.called = True
if not query: if not query:
return [] return []
if categories: if categories:
return [r for r in self._results if r["category"] in categories][:limit] results = [r for r in self._results if r["category"] in categories]
return self._results[:limit] else:
results = self._results
return results[offset : offset + limit]

View File

@@ -31,10 +31,27 @@ def temp_db_path():
@pytest.fixture @pytest.fixture
def temp_csv_path(): def temp_csv_path():
"""Create a temporary CSV file with test data.""" """Create a temporary CSV file with test data."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f: with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
# Write test data in the same format as danbooru_e621_merged.csv # Write test data in the same format as danbooru_e621_merged.csv
# Format: tag_name,category,post_count,aliases # Format: tag_name,category,post_count,aliases
# Include multiple tags starting with "1" to test popularity-based ranking
f.write('1girl,0,6008644,"1girls,sole_female"\n') f.write('1girl,0,6008644,"1girls,sole_female"\n')
f.write('1boy,0,1405457,"1boys,sole_male"\n')
f.write('1:1,14,377032,""\n')
f.write('16:9,14,152866,""\n')
f.write('1other,0,70962,""\n')
f.write('16:10,14,14739,""\n')
f.write('1990s_(style),0,9369,""\n')
f.write('1_eye,0,7179,""\n')
f.write('1:2,14,5865,""\n')
f.write('1980s_(style),0,5665,""\n')
f.write('1koma,0,4384,""\n')
f.write('1_horn,0,2122,""\n')
f.write('101_dalmatian_street,3,1933,""\n')
f.write('1upgobbo,3,1731,""\n')
f.write('14:9,14,1038,""\n')
f.write('highres,5,5256195,"high_res,high_resolution,hires"\n') f.write('highres,5,5256195,"high_res,high_resolution,hires"\n')
f.write('solo,0,5000954,"alone,female_solo,single"\n') f.write('solo,0,5000954,"alone,female_solo,single"\n')
f.write('hatsune_miku,4,500000,"miku"\n') f.write('hatsune_miku,4,500000,"miku"\n')
@@ -86,7 +103,7 @@ class TestTagFTSIndexBuild:
fts.build_index() fts.build_index()
assert fts.is_ready() is True assert fts.is_ready() is True
assert fts.get_indexed_count() == 10 assert fts.get_indexed_count() == 24
def test_build_index_nonexistent_csv(self, temp_db_path): def test_build_index_nonexistent_csv(self, temp_db_path):
"""Test that build_index handles missing CSV gracefully.""" """Test that build_index handles missing CSV gracefully."""
@@ -187,6 +204,110 @@ class TestTagFTSIndexSearch:
results = populated_fts.search("girl", limit=1) results = populated_fts.search("girl", limit=1)
assert len(results) <= 1 assert len(results) <= 1
def test_search_tag_name_prefix_match_priority(self, populated_fts):
"""Test that tag_name prefix matches rank higher than alias matches."""
results = populated_fts.search("1", limit=20)
assert len(results) > 0, "Should return results for '1'"
# Find first alias match (if any)
first_alias_idx = None
for i, result in enumerate(results):
if result.get("matched_alias"):
first_alias_idx = i
break
# All tag_name prefix matches should come before alias matches
if first_alias_idx is not None:
for i in range(first_alias_idx):
assert results[i]["tag_name"].lower().startswith("1"), (
f"Tag at index {i} should start with '1' before alias matches"
)
def test_search_ranks_popular_tags_higher(self, populated_fts):
"""Test that tags with higher post_count rank higher among prefix matches."""
results = populated_fts.search("1", limit=20)
# Filter to only tag_name prefix matches
prefix_matches = [r for r in results if r["tag_name"].lower().startswith("1")]
assert len(prefix_matches) > 1, "Should have multiple prefix matches"
# Verify descending post_count order among prefix matches
for i in range(len(prefix_matches) - 1):
assert (
prefix_matches[i]["post_count"] >= prefix_matches[i + 1]["post_count"]
), (
f"Tags should be sorted by post_count: {prefix_matches[i]['tag_name']} ({prefix_matches[i]['post_count']}) >= {prefix_matches[i + 1]['tag_name']} ({prefix_matches[i + 1]['post_count']})"
)
def test_search_pagination_ordering_consistency(self, populated_fts):
"""Test that pagination maintains consistent ordering by post_count."""
page1 = populated_fts.search("1", limit=10, offset=0)
page2 = populated_fts.search("1", limit=10, offset=10)
assert len(page1) > 0, "Page 1 should have results"
assert len(page2) > 0, "Page 2 should have results"
# Page 2 max post_count should be <= Page 1 min post_count
page1_min_posts = min(r["post_count"] for r in page1)
page2_max_posts = max(r["post_count"] for r in page2)
assert page2_max_posts <= page1_min_posts, (
f"Page 2 max post_count ({page2_max_posts}) should be <= Page 1 min post_count ({page1_min_posts})"
)
def test_search_returns_popular_tags_higher(self, populated_fts):
"""Test that search returns popular tags (higher post_count) first."""
results = populated_fts.search("1", limit=5)
assert len(results) >= 2, "Need at least 2 results to compare"
# 1girl has 6M posts, should be ranked first
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
assert girl_result is not None, "1girl should be in results"
assert results[0]["tag_name"] == "1girl", (
"1girl should be first due to highest post_count"
)
# Find a tag with significantly fewer posts
low_post_result = next((r for r in results if r["post_count"] < 10000), None)
if low_post_result:
assert girl_result["post_count"] > low_post_result["post_count"], (
f"1girl (6M posts) should have higher post_count than {low_post_result['tag_name']} ({low_post_result['post_count']} posts)"
)
def test_search_popularity_ordering(self, populated_fts):
"""Test that results are ordered by post_count (popularity)."""
results = populated_fts.search("1", limit=20)
# Get 1girl and 1boy results for comparison
girl_result = next((r for r in results if r["tag_name"] == "1girl"), None)
boy_result = next((r for r in results if r["tag_name"] == "1boy"), None)
assert girl_result is not None, "1girl should be in results"
assert boy_result is not None, "1boy should be in results"
# 1girl: 6M posts, 1boy: 1.4M posts
assert girl_result["post_count"] == 6008644, "1girl should have 6M posts"
assert boy_result["post_count"] == 1405457, "1boy should have 1.4M posts"
# 1girl should rank higher due to higher post_count
girl_rank = results.index(girl_result)
boy_rank = results.index(boy_result)
assert girl_rank < boy_rank, (
f"1girl should rank higher than 1boy due to higher post_count "
f"(girl rank: {girl_rank}, boy rank: {boy_rank})"
)
# Verify results are sorted by post_count descending
for i in range(len(results) - 1):
assert results[i]["post_count"] >= results[i + 1]["post_count"], (
f"Results should be sorted by post_count descending: "
f"{results[i]['tag_name']} ({results[i]['post_count']}) >= "
f"{results[i + 1]['tag_name']} ({results[i + 1]['post_count']})"
)
class TestAliasSearch: class TestAliasSearch:
"""Tests for alias search functionality.""" """Tests for alias search functionality."""
@@ -204,7 +325,9 @@ class TestAliasSearch:
results = populated_fts.search("miku") results = populated_fts.search("miku")
assert len(results) >= 1 assert len(results) >= 1
hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None) hatsune_result = next(
(r for r in results if r["tag_name"] == "hatsune_miku"), None
)
assert hatsune_result is not None assert hatsune_result is not None
assert hatsune_result["matched_alias"] == "miku" assert hatsune_result["matched_alias"] == "miku"
@@ -214,7 +337,9 @@ class TestAliasSearch:
results = populated_fts.search("hatsune") results = populated_fts.search("hatsune")
assert len(results) >= 1 assert len(results) >= 1
hatsune_result = next((r for r in results if r["tag_name"] == "hatsune_miku"), None) hatsune_result = next(
(r for r in results if r["tag_name"] == "hatsune_miku"), None
)
assert hatsune_result is not None assert hatsune_result is not None
assert "matched_alias" not in hatsune_result assert "matched_alias" not in hatsune_result
@@ -301,7 +426,9 @@ class TestSlashPrefixAliases:
@pytest.fixture @pytest.fixture
def fts_with_slash_aliases(self, temp_db_path): def fts_with_slash_aliases(self, temp_db_path):
"""Create an FTS index with slash-prefixed aliases.""" """Create an FTS index with slash-prefixed aliases."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, encoding="utf-8") as f: with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
# Format: tag_name,category,post_count,aliases # Format: tag_name,category,post_count,aliases
f.write('long_hair,0,4350743,"/lh,longhair,very_long_hair"\n') f.write('long_hair,0,4350743,"/lh,longhair,very_long_hair"\n')
f.write('breasts,0,3439214,"/b,boobs,oppai"\n') f.write('breasts,0,3439214,"/b,boobs,oppai"\n')
@@ -380,7 +507,15 @@ class TestCategoryMappings:
def test_category_name_to_ids_complete(self): def test_category_name_to_ids_complete(self):
"""Test that CATEGORY_NAME_TO_IDS includes all expected names.""" """Test that CATEGORY_NAME_TO_IDS includes all expected names."""
expected_names = ["general", "artist", "copyright", "character", "meta", "species", "lore"] expected_names = [
"general",
"artist",
"copyright",
"character",
"meta",
"species",
"lore",
]
for name in expected_names: for name in expected_names:
assert name in CATEGORY_NAME_TO_IDS assert name in CATEGORY_NAME_TO_IDS
assert isinstance(CATEGORY_NAME_TO_IDS[name], list) assert isinstance(CATEGORY_NAME_TO_IDS[name], list)

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