Compare commits

...

216 Commits

Author SHA1 Message Date
Will Miao
6c03aa1430 feat: add v0.9.9 release features and update version 2025-11-03 22:40:03 +08:00
pixelpaws
5376fd8724 Merge pull request #640 from willmiao/codex/optimize-confirmation-modal-message
feat(updates): improve check updates confirmation
2025-11-03 22:37:57 +08:00
pixelpaws
6dea9a76bc feat(updates): improve check updates confirmation 2025-11-03 22:36:57 +08:00
Will Miao
d73903e82e feat: prevent duplicate banner entries in recent history
Add duplicate detection to banner recording to prevent multiple entries
for the same banner ID in recent history. This prevents duplicate history
entries when pages refresh or banners are shown multiple times.

- Check if banner ID already exists in recentHistory before adding
- Return early if duplicate found to prevent adding same banner multiple times
- Add comprehensive tests for banner history functionality including:
  - Adding new banners to history
  - Preventing duplicate entries
  - Handling multiple different banners
- Clear history between tests to ensure test isolation
2025-11-03 20:13:48 +08:00
Will Miao
4862419b61 feat: refactor banner service and add comprehensive tests
- Remove legacy community support banner tracking variables and logic
- Simplify banner dismissal handling by checking dismissal state before marking
- Replace timer-based community support banner with immediate registration
- Clean up unused constants and legacy storage keys
- Add comprehensive test suite with mocked dependencies
- Improve code maintainability and test coverage
2025-11-03 19:50:35 +08:00
Will Miao
e6e7df7454 feat: add Chinese localization for community support banner
- Update zh-CN locale with Chinese text for community support section
- Add support for Afdian platform for Chinese users alongside existing Ko-fi
- Implement language-based URL routing for support links and tutorials
- Chinese users now see localized content with appropriate payment options (Alipay/WeChat)
- Maintains existing functionality for non-Chinese users
2025-11-03 18:00:25 +08:00
Will Miao
30f9e3e2ec feat(loras): add drag event callbacks and preview suppression
- Add onDragStart and onDragEnd callbacks to initDrag function
- Implement preview suppression during and briefly after strength dragging
- Clear preview timer on drag start/end to prevent tooltip conflicts
- Update tests to verify drag callbacks are properly triggered

This prevents tooltip previews from interfering with drag interactions and provides better control over drag lifecycle events.
2025-11-03 12:18:59 +08:00
Will Miao
707d0cb8a4 feat(lora-loader): add trigger word update on LoRA syntax edits
Add test coverage for trigger word refresh functionality when LoRA syntax is edited in the input widget. The test verifies that after modifying LoRA syntax in the input field, the connected trigger words are properly updated to reflect the active LoRAs.

Additionally, implement the actual trigger word update logic in lora_loader.js by calling updateConnectedTriggerWords after merging LoRAs, ensuring the UI stays synchronized with the current LoRA state.
2025-11-03 12:03:21 +08:00
Will Miao
56ea7594ce feat: simplify portable installation instructions
Remove redundant details about settings configuration to make installation steps clearer and more concise. The simplified instructions now focus on essential steps without unnecessary explanations about placeholder values and automatic registry generation.
2025-11-03 09:00:53 +08:00
Will Miao
389e46c251 feat(sidebar): add force initialization option and improve state management
- Add `forceInitialize` option to sidebar initialization to bypass disabled setting
- Refactor sidebar toggle logic to handle initialization promises more reliably
- Improve cleanup behavior when sidebar is disabled
- Ensure proper DOM updates when sidebar state changes
- Maintain container layout consistency during sidebar operations
2025-11-03 07:15:29 +08:00
pixelpaws
6db17e682a Merge pull request #639 from willmiao/codex/add-setting-to-toggle-folder-sidebar, fixes #630
feat: add setting to toggle folder sidebar visibility
2025-11-03 07:04:08 +08:00
pixelpaws
94e0308a12 feat(settings): allow hiding folder sidebar 2025-11-03 06:39:13 +08:00
pixelpaws
1f9f821576 Merge pull request #636 from willmiao/codex/reset-community-support-banner-logic
fix: reset community support banner timing
2025-11-02 22:40:25 +08:00
pixelpaws
57933dfba6 fix(banner): reset community support schedule 2025-11-02 22:30:33 +08:00
pixelpaws
c50bee7757 Merge pull request #635 from willmiao/codex/design-banner-message-review-feature
chore(ui): improve notification center accessibility
2025-11-02 21:04:07 +08:00
pixelpaws
4e3ee843f9 chore(ui): improve notification center accessibility 2025-11-02 20:59:00 +08:00
Will Miao
7e40f6fcb9 feat: add GGUF loader metadata extractor support, fixes #627
Add GGUFLoaderExtractor class to handle metadata extraction for GGUF model loaders. Register extractor for both LoaderGGUF and LoaderGGUFAdvanced node types to capture checkpoint metadata from gguf_name input parameter. This enables proper metadata tracking for GGUF model files used in the system.
2025-11-02 10:20:44 +08:00
Will Miao
7976956b6b feat: add model_cache to plugin folder cleanup skip list
Update the plugin cleanup process to preserve the model_cache folder
along with settings.json and civitai. This prevents accidental deletion
of cached model files during plugin updates, improving performance by
avoiding unnecessary model re-downloads.
2025-11-02 10:09:56 +08:00
Will Miao
adce5293d5 feat: add model_cache directory to gitignore 2025-11-02 10:01:15 +08:00
pixelpaws
c2db5eb6df Merge pull request #634 from willmiao/codex/refactor-config-to-handle-default-library
fix: drop template default library when saving ComfyUI paths
2025-11-02 09:12:26 +08:00
pixelpaws
f958ecdf18 Merge pull request #633 from willmiao/codex/update-settings-path-handling-for-portable-mode
Fix portable settings to use project root storage
2025-11-02 09:11:17 +08:00
pixelpaws
ef0bcc6cf1 fix(config): remove template default library before saving paths 2025-11-02 09:09:36 +08:00
pixelpaws
285428ad3a fix(portable): use project root for settings storage 2025-11-02 09:07:57 +08:00
pixelpaws
ee18cff3d9 Merge pull request #626 from willmiao/codex/update-model_update_versions-primary-key
fix: support per-model version ids in update service
2025-10-30 23:23:11 +08:00
pixelpaws
1be3235564 fix(model-updates): support per-model version ids 2025-10-30 23:15:23 +08:00
pixelpaws
a92883509a Merge pull request #624 from willmiao/codex/clarify-multi-libraries-support-for-comfyui, see #623
Add portable settings mode toggle
2025-10-30 14:18:41 +08:00
pixelpaws
ce42d83ce9 feat(settings): add portable settings toggle 2025-10-30 14:08:21 +08:00
Will Miao
077cf7b574 feat(metadata): Add extractors for NunchakuFluxDiTLoader and NunchakuQwenImageDiTLoader nodes, fixes #621 2025-10-29 23:19:11 +08:00
Will Miao
b99d78bda6 feat(nodes): enhance LoRA loading with path support and add tests
- Allow direct file paths in addition to registered LoRA names
- Add graceful handling for missing LoRA files with warning logs
- Add comprehensive unit tests for missing LoRA file handling
- Ensure backward compatibility with existing LoRA loading behavior
2025-10-29 22:39:08 +08:00
Will Miao
39586f4a20 feat: add LoRA syntax utilities and comprehensive test suite, fixes #600
- Implement core LoRA syntax manipulation functions including:
  - applyLoraValuesToText for updating LoRA strengths and clip values
  - normalizeStrengthValue for consistent numeric formatting
  - shouldIncludeClipStrength for clip strength inclusion logic
  - cleanupLoraSyntax for text normalization
  - debounce utility for input handling

- Add comprehensive test suite covering all utility functions
- Include edge cases for clip strength handling, numeric formatting, and syntax cleanup
- Support both basic and expanded LoRA syntax formats with proper value preservation
- Enable debounced input synchronization for better performance

The utilities provide robust handling of LoRA syntax patterns while maintaining compatibility with existing ComfyUI workflows.
2025-10-29 22:13:54 +08:00
Will Miao
4ef750b206 feat: improve dropdown menu responsiveness
Update dropdown menu CSS to use max-content and max() function for better responsive behavior. Replace fixed min-width with dynamic width calculation to ensure proper content fitting across different screen sizes while maintaining dropdown functionality.
2025-10-29 16:06:08 +08:00
pixelpaws
9d3d93823d Merge pull request #620 from willmiao/codex/update-refresh-button-titles
feat: clarify refresh menu copy
2025-10-29 15:47:08 +08:00
pixelpaws
45c1113b72 feat(ui): clarify refresh menu labels 2025-10-29 15:39:42 +08:00
Will Miao
e10717dcda feat(ui): improve update controls styling and error handling
- Add disabled and loading states for control group buttons with appropriate cursor and opacity styling
- Enhance dropdown toggle active state styling for update filter group
- Improve dropdown toggle layout with flex centering
- Add disabled state styling for dropdown items
- Refactor model update check to use shared helper function, removing redundant success handling and simplifying error flow
- Maintain existing functionality while improving user experience and code maintainability
2025-10-29 14:38:11 +08:00
Will Miao
315ab6f70b feat(ui): update filter button icon for better visual clarity
Changed the sync icon to an exclamation circle icon on the updates filter button to provide clearer visual indication of the filter's purpose and improve user interface consistency.
2025-10-29 09:45:31 +08:00
Will Miao
cf4d654c4b feat: improve file size extraction logic with primary model preference
Refactor `_extract_size_bytes` method to prioritize primary model files when calculating size. The new implementation:
- Extracts size parsing into separate `parse_size` function
- Adds logic to prefer files marked as both "model" type and "primary"
- Falls back to first valid size if no primary model file found
- Adds comprehensive tests for primary preference and fallback behavior

This ensures more accurate size reporting for model files, particularly when multiple file types are present in the response.
2025-10-29 09:16:29 +08:00
pixelpaws
569c829709 Merge pull request #619 from willmiao/codex/add-check-for-updates-in-bulk-context-menu
feat(bulk): add selected update check
2025-10-29 08:56:26 +08:00
pixelpaws
de05b59f29 test(routes): cover snake case model id payload 2025-10-29 07:33:58 +08:00
Will Miao
70a282a6c0 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-10-29 07:33:00 +08:00
Will Miao
b10bcf7e78 feat: add update availability filter to model list
Add a new filter option to show only models with available updates across all supported languages. This includes:

- Adding "updates" filter translations in all locale files (de, en, es, fr, he, ja, ko)
- Extending BaseModelApiClient to handle update_available_only query parameter
- Implementing update filter button in PageControls component with event listeners
- Adding corresponding CSS styles for active filter state

The feature allows users to quickly identify and focus on models that have updates available, improving the update management workflow.
2025-10-29 07:32:53 +08:00
pixelpaws
5fb10263f3 Merge pull request #618 from willmiao/codex/fix-database-schema-conflict-error, fixes #617
fix: prevent model update refresh failures on legacy schemas
2025-10-28 22:28:26 +08:00
pixelpaws
9e76c9783e fix(update-service): backfill unique constraint for status table 2025-10-28 22:19:21 +08:00
pixelpaws
7770976513 Merge pull request #616 from willmiao/codex/analyze-update-metadata-refresh-duration
fix: ignore removed civitai models during update refresh
2025-10-28 21:55:39 +08:00
pixelpaws
dc1f7ab6fe fix: handle civitai not found responses 2025-10-28 21:47:30 +08:00
pixelpaws
32b1d6c561 Merge pull request #614 from willmiao/codex/extend-modelcache-for-model_id-indexing
feat: cache versions by model id for faster lookup
2025-10-28 18:48:17 +08:00
pixelpaws
5264e49f2a feat(cache): index versions by model id 2025-10-28 18:39:37 +08:00
Will Miao
ce3adaf831 feat: disable automatic refresh in model versions fetch 2025-10-28 09:11:39 +08:00
Will Miao
e2f3e57f5c feat: replace window.confirm with modal for version deletion
Replace the native browser confirmation dialog with a custom modal when deleting model versions. This provides better UX with consistent styling, allows displaying version information (name, preview, metadata), and gives users more context before confirming deletion.

Key changes:
- Added modalManager import
- Created showDeleteVersionModal function to display deletion confirmation modal
- Enhanced performDeleteVersion function with better error handling and button state management
- Modal shows version preview, name, base model, and metadata
- Improved accessibility with proper modal interactions
- Maintains existing deletion functionality with enhanced user experience
2025-10-27 22:46:36 +08:00
Will Miao
5c2349ff42 feat: remove external links from model version names
Remove Civitai external links from model version names in the versions tab to improve UI consistency and prevent unintended navigation. Version names are now displayed as plain text spans instead of clickable links while maintaining the same visual styling.
2025-10-27 21:14:58 +08:00
Will Miao
50eee8c373 feat: add clickable version rows with Civitai links
- Add CSS class `is-clickable` to make version rows appear interactive
- Implement URL builder function for Civitai model version links
- Make version names clickable links that open Civitai pages in new tabs
- Add tooltips and data attributes for enhanced user experience
- Pass modelId to version rendering to support external linking

This improves user navigation by allowing direct access to model versions on Civitai without leaving the application.
2025-10-27 20:59:32 +08:00
pixelpaws
f89b792535 Merge pull request #609 from willmiao/codex/add-progress-logging-for-check-updates
feat: add progress logging to update refresh workflow
2025-10-27 20:57:14 +08:00
pixelpaws
6d0ea2841c feat(updates): add progress logging to refresh service 2025-10-27 20:41:07 +08:00
Will Miao
98678a8698 feat(loras): add backspace key handling for LoRA deletion with input focus check, fixes #601
Add keyboard navigation support for deleting selected LoRA entries using Backspace key while preventing accidental deletion when editing strength input values. The implementation includes:

- Backspace key now deletes selected LoRA when pressed outside strength inputs
- Backspace is ignored when focused on strength input fields to allow normal text editing
- Added corresponding test cases to verify both deletion and non-deletion scenarios

This prevents users from accidentally deleting LoRA entries while editing strength values and provides intuitive keyboard controls for LoRA management.
2025-10-27 19:39:49 +08:00
pixelpaws
5326fa2970 Merge pull request #607 from willmiao/codex/fix-404-errors-for-example-image-links, see #590
Preserve local previews when CivitAI images 404
2025-10-27 19:09:41 +08:00
pixelpaws
90547670a2 Merge pull request #608 from willmiao/codex/fix-example-images-download-path-issue
fix: allow legacy library folders when validating example image paths
2025-10-27 18:25:49 +08:00
pixelpaws
4753206c52 fix(example-images): accept legacy library folders 2025-10-27 18:21:43 +08:00
pixelpaws
613aa3b1c3 fix(example-images): preserve local previews on 404 2025-10-27 18:21:14 +08:00
Will Miao
a6b704d4b4 feat: remove version ID display from model versions tab
Remove the .version-id CSS class and corresponding HTML element that displayed version IDs in the model versions tab. This simplifies the UI by removing redundant information since version IDs are already available elsewhere in the interface and were causing visual clutter.
2025-10-27 12:58:15 +08:00
Will Miao
227d06c736 feat: adjust image cropping to prioritize face visibility in LoRA modal
Update CSS for version media images to bias cropping toward the upper region, ensuring faces remain visible when images are cropped. This improves user experience by maintaining important visual content within the constrained display area.
2025-10-27 12:42:25 +08:00
Will Miao
8508763831 feat: improve video URL detection to handle query parameters
Enhanced the `isVideoUrl` function to more accurately detect video URLs by:
- Adding support for URL query parameters and fragments
- Creating helper function `extractExtension` to handle URI decoding
- Checking multiple candidate values from different parts of the URL
- Maintaining backward compatibility with existing video detection

This improves reliability when detecting video URLs that contain query parameters or encoded characters.
2025-10-27 12:21:51 +08:00
Will Miao
136d3153fa feat: add modal file path resolution and synchronization
- Add getActiveModalFilePath function to resolve current file path from DOM state
- Add updateModalFilePathReferences function to synchronize file path across all modal controls
- Refactor existing code to use new path resolution functions
- Ensure metadata interactions remain in sync after file renames or moves
- Improve robustness by handling cases where DOM state hasn't been initialized yet
2025-10-27 12:04:48 +08:00
Will Miao
49bdf77040 feat: improve multipart file extension detection
Refactor _get_multipart_ext method to use known suffixes list for more reliable file extension detection. The new implementation handles compound file extensions like '.metadata.json.bak' and '.safetensors' by checking against predefined suffixes in order of length. Falls back to existing logic for unknown file types. This improves accuracy when working with model files that have complex naming conventions.
2025-10-27 11:15:16 +08:00
pixelpaws
f4dcd89835 Merge pull request #603 from willmiao/codex/analyze-lora-manager-recipe-detection-issue
fix(recipes): normalize relocated preview paths
2025-10-27 09:55:43 +08:00
pixelpaws
139e915711 fix(recipes): normalize relocated preview paths 2025-10-27 09:50:25 +08:00
pixelpaws
22eda58074 Merge pull request #599 from willmiao/codex/fix-settings.json-initialization-behavior
Fix settings template preservation on restart
2025-10-27 00:03:49 +08:00
pixelpaws
fb91cf4df2 fix(settings): preserve template settings file 2025-10-26 23:57:59 +08:00
Will Miao
e0332571da feat(settings): improve library bootstrap logic and path handling
- Normalize folder paths before library bootstrap to ensure consistent structure
- Add _has_configured_paths helper to detect valid folder configurations
- Enhance bootstrap logic to handle edge cases with single libraries and empty paths
- Update library payload construction to use normalized paths
- Add example settings file changes to demonstrate new path structure

The changes ensure more robust library initialization when folder paths are configured at the top level but not properly propagated to individual libraries.
2025-10-26 23:40:07 +08:00
pixelpaws
2d4bc47746 Merge pull request #597 from willmiao/codex/refactor-settings-manager-for-core-keys
Reduce core settings persistence to essential keys
2025-10-26 20:01:08 +08:00
pixelpaws
38e766484e fix(settings): restrict minimal persistence keys 2025-10-26 19:53:43 +08:00
Will Miao
b5ee4a6408 feat(settings): enhance settings handling and add startup messages, fixes #593 and fixes #594
- Add standalone mode detection via LORA_MANAGER_STANDALONE environment variable
- Improve error handling for settings file loading with specific JSON decode errors
- Add startup messages system to communicate configuration warnings and errors to users
- Include settings file path and startup messages in settings API response
- Automatically save settings when bootstrapping from defaults due to missing/invalid settings file
- Add configuration warnings collection for environment variables and other settings issues

The changes improve robustness of settings management and provide better user feedback when configuration issues occur.
2025-10-26 18:07:00 +08:00
Will Miao
7892df21ec feat: add dynamic loading overlay creation with accessibility
Add fallback DOM element creation in LoadingManager constructor to handle cases where loading overlay elements don't exist in the DOM. This ensures the loading functionality works even when the required HTML elements are missing.

- Create loading overlay, content container, progress bar, and status text elements dynamically
- Add ARIA attributes to progress bar for accessibility
- Move details container insertion to use the created loadingContent reference
- Maintain existing functionality while adding robustness for missing DOM elements
2025-10-26 10:52:24 +08:00
Will Miao
188fe407b6 feat(download): sync downloaded versions with update tracking
Add automatic synchronization of downloaded model versions with the update tracking system. After a successful download, the system now resolves model and version IDs from the download response and updates the update service with the newly downloaded version along with any existing local versions.

This ensures that:
- Update tracking accurately reflects which versions are available locally
- The system properly tracks both newly downloaded and existing versions
- Failed sync operations are gracefully handled with appropriate logging
- Support is included for LoRA, checkpoint, and embedding model types
2025-10-26 10:42:09 +08:00
Will Miao
600afdcd92 feat: add update badge to model modal versions tab
- Add CSS styling for tab badges with update indicator animation
- Include update_available flag in model data parsing
- Display animated badge on versions tab when updates are available
- Improve tab button layout with flexbox alignment and spacing
2025-10-26 10:11:04 +08:00
Will Miao
994fa4bd43 feat: enhance model version download with progress tracking
- Set refresh to true when fetching model update versions to ensure latest data
- Refactor handleDownloadVersion to be async and accept button parameter
- Add progress tracking and WebSocket integration for download operations
- Implement button state management during download process
- Add error handling and cleanup for download operations
- Update download action to await async download handler
2025-10-26 09:39:42 +08:00
Will Miao
51098f2829 feat: update model versions tab styling and refresh behavior
- Rename CSS class from 'version-name' to 'versions-tab-version-name' for better specificity
- Remove color-mix styling from version title for cleaner appearance
- Set refresh parameter to false in versions fetch to prevent unnecessary data reloads
- Maintains same functionality while improving performance and code organization
2025-10-26 09:13:59 +08:00
Will Miao
795b9e8418 feat: enhance model version context with file metadata
- Rename `preview_overrides` to `version_context` to better reflect expanded purpose
- Add file_path and file_name fields to version serialization
- Update method names and parameter signatures for consistency
- Include file metadata from cache in version context building
- Maintain backward compatibility with existing preview URL functionality

The changes provide more comprehensive version information including file details while maintaining existing preview override behavior.
2025-10-26 08:53:53 +08:00
Will Miao
9ca2b9dd56 feat: add model updates check to global context menu
Add a new "Check Model Updates" option to the global context menu that allows users to manually check for model updates. This includes:

- Adding refreshUpdates endpoint to API configuration
- Implementing checkModelUpdates method with proper loading states
- Adding internationalization support for update messages
- Handling success/error states with appropriate user feedback
- Automatically reloading models after update check completes

The feature provides users with manual control over update checks and improves visibility into model update availability.
2025-10-25 21:32:08 +08:00
Will Miao
d77b6d78b7 feat(model-updates): filter records without updates in refresh response
Add logic to only include model update records that have actual updates in the refresh response. This improves API efficiency by reducing payload size and only returning relevant data to clients.

The change:
- Adds filtering in ModelUpdateHandler.refresh_model_updates to check has_update method
- Only serializes records that have updates available
- Updates corresponding test to verify filtering behavior

This prevents returning unnecessary data for models that don't have updates available.
2025-10-25 21:31:36 +08:00
Will Miao
427e7a36d5 feat: improve model update detection logic
Update ModelUpdateRecord.has_update() to only detect updates when a newer remote version exists than the latest local version. Previously, any missing remote version would trigger an update, which could include older versions that shouldn't be considered updates.

- Add logic to find the maximum version ID in library
- Only return True for remote versions newer than the latest local version
- Add comprehensive unit tests for the new update detection behavior
- Update docstring to reflect the new logic
2025-10-25 21:31:01 +08:00
Will Miao
c90306cc9b feat: display abbreviated base model labels in model cards
- Add BASE_MODEL_ABBREVIATIONS mapping and getBaseModelAbbreviation utility function
- Replace full base model names with abbreviated versions in ModelCard component
- Implement fallback abbreviation generation for unknown base models
- Maintain full base model name in tooltip for accessibility
- Improve card layout by reducing label width while preserving information
2025-10-25 17:04:16 +08:00
Will Miao
5fe0660c64 feat: add update available indicator to model cards
- Add CSS custom properties for update badge styling in both light and dark themes
- Create new card header info layout with flexbox for better content organization
- Implement model-update-badge component with glow effects and proper spacing
- Add has-update class to cards when updates are available with visual border indicators
- Update ModelCard.js to conditionally render update badges based on model data
- Include internationalization support for update badge labels and tooltips

The changes provide users with clear visual indicators when model updates are available, improving the user experience by making update status immediately visible without requiring manual checks.
2025-10-25 16:41:35 +08:00
Will Miao
2abb5bf122 feat: add update_available flag to model services
Add update_available field to checkpoint, embedding, and LoRA service response formatting. The flag indicates whether a model update is available and defaults to false when not specified.

Include comprehensive tests to verify the update flag is properly included in formatted responses and defaults to false when not present in the payload.
2025-10-25 16:29:54 +08:00
Will Miao
bb65527469 feat: add database migration system for model update schema
Add migration support to handle schema changes without data loss. Instead of dropping and recreating tables, the system now:
- Uses CREATE TABLE IF NOT EXISTS for initial table creation
- Adds _apply_migrations method to handle incremental schema updates
- Adds _get_table_columns helper to inspect existing table structure
- Adds new columns to model_update_versions table (sort_index, name, base_model, released_at, size_bytes, preview_url, is_in_library, should_ignore)
- Adds should_ignore_model column to model_update_status table

This ensures existing databases are upgraded gracefully while preserving user data.
2025-10-25 16:05:39 +08:00
Will Miao
d9a6db3359 feat: optimize model update checking with bulk operations
- Refactor update filter logic to use bulk update checks when available
- Add annotation method to attach update flags to response items
- Improve performance by reducing API calls for update status checks
- Maintain backward compatibility with fallback to individual checks
- Handle edge cases and logging for failed update status resolutions
2025-10-25 15:31:33 +08:00
Will Miao
58cafdb713 feat: add model version management endpoints
- Add set_version_update_ignore endpoint to toggle ignore status for specific versions
- Add get_model_versions endpoint to retrieve version details with optional refresh
- Update serialization to include version-specific data and preview overrides
- Modify database schema to support version-level ignore tracking
- Improve error handling for rate limiting and missing models

These changes enable granular control over version updates and provide better visibility into model version status.
2025-10-25 14:54:23 +08:00
pixelpaws
0594e278b6 Merge pull request #592 from willmiao/codex/update-preview-download-logic-for-nsfw-settings
feat(preview): respect blur mature content setting
2025-10-25 06:49:51 +08:00
pixelpaws
807425f12a feat(preview): respect blur mature content setting 2025-10-25 06:43:03 +08:00
pixelpaws
aa4b1ccc25 Update FUNDING.yml 2025-10-24 22:23:32 +08:00
pixelpaws
58255ec28b Update FUNDING.yml 2025-10-24 22:12:36 +08:00
pixelpaws
d62b84693d Update FUNDING.yml 2025-10-24 22:10:33 +08:00
Will Miao
df75c7e68d feat: enhance Lora modal recipes section with new header and card components
- Add comprehensive recipes header with title, description, and view-all button
- Implement recipe card grid layout with responsive design
- Add recipe cards featuring titles, metadata badges, and copy functionality
- Include theme-aware styling for both light and dark modes
- Improve visual hierarchy and user interaction with hover states and transitions
2025-10-24 20:27:59 +08:00
pixelpaws
c5c7fdf54f Merge pull request #591 from willmiao/codex/investigate-share-button-network-error
Fix recipe share filename sanitization
2025-10-24 15:22:04 +08:00
pixelpaws
49e0deeff3 fix(recipes): sanitize shared recipe filenames 2025-10-24 15:17:12 +08:00
pixelpaws
0c20701bef Merge pull request #589 from willmiao/codex/fix-download-stalling-issues
Fix stalled downloads by adding stall detection and reconnect logic
2025-10-23 17:40:31 +08:00
pixelpaws
faa26651dd fix(download): recover stalled transfers automatically 2025-10-23 17:25:38 +08:00
pixelpaws
2eae8a7729 Merge pull request #587 from willmiao/codex/evaluate-file-validation-improvements-for-lora-manager
Ensure downloader rejects empty or truncated files
2025-10-23 12:17:13 +08:00
pixelpaws
dde2b2a960 fix(downloader): enforce file size integrity checks 2025-10-23 11:55:39 +08:00
pixelpaws
4a9089d3dd Merge pull request #586 from willmiao/codex/analyze-and-propose-fix-for-header-value-error
fix: sanitize aiohttp header limit overrides
2025-10-23 11:23:08 +08:00
pixelpaws
3244a5f1a1 fix(lora-manager): sanitize header limit overrides 2025-10-23 11:13:31 +08:00
Will Miao
449c1e9d10 feat(i18n): add new UI text for model management features
Add localization strings for new model management functionality:
- Copy checkpoint and embedding name actions
- Send checkpoint and embedding to ComfyUI workflow
- Error messages for missing model paths and workflow compatibility
- Node selection validation messages

These additions support upcoming features for better model handling and workflow integration.
2025-10-23 11:11:13 +08:00
Will Miao
d0aa916683 feat(node-registry): add support to send checkpoint/diffusion model to workflow
- Add capabilities parsing and validation for node registration
- Implement widget_names extraction from capabilities with type safety
- Add supports_lora boolean conversion in capabilities
- Include comfy_class fallback to node_type when missing
- Add new update_node_widget API endpoint for bulk widget updates
- Improve error handling and input validation for widget updates
- Remove unused parameters from node selector event setup function

These changes improve node metadata handling and enable dynamic widget management capabilities.
2025-10-23 10:44:48 +08:00
pixelpaws
13433f8cd2 Merge pull request #585 from willmiao/codex/fix-model_type-not-updating-for-checkpoints
fix: apply adjust_cached_entry during model reconciliation
2025-10-23 08:33:33 +08:00
pixelpaws
8d336320c0 fix(scanner): apply metadata adjustments during reconciliation 2025-10-23 07:34:35 +08:00
pixelpaws
d945c58d51 Merge pull request #583 from willmiao/codex/analyze-zstd-content-encoding-error
fix: disable compression in default downloader headers
2025-10-22 10:21:17 +08:00
pixelpaws
acaf122346 fix(downloader): request identity encoding by default 2025-10-22 10:17:39 +08:00
pixelpaws
713759b411 Merge pull request #582 from willmiao/codex/fix-model-type-adjustment-in-scanner
Fix checkpoint model type when hydrating persisted cache
2025-10-21 22:59:55 +08:00
pixelpaws
c5175bb870 fix(checkpoints): preserve model type on persisted load 2025-10-21 22:55:00 +08:00
pixelpaws
e63ef8d031 Merge pull request #581 from willmiao/codex/fix-typeerror-in-autocomplete.js
fix: clean up autocomplete event handlers
2025-10-21 19:33:02 +08:00
pixelpaws
e043537241 fix(autocomplete): detach listeners when dropdown removed 2025-10-21 19:28:22 +08:00
Will Miao
46126f9950 feat(extensions): add auto path correction toggle for LoRA Manager, fixes #410 2025-10-21 18:47:42 +08:00
Will Miao
f4eb916914 feat: standardize LoRA Manager frontend with CSS classes and improved styles
- Replace inline styles with CSS classes for better maintainability
- Update class names to use consistent 'lm-' prefix across components
- Add comprehensive CSS stylesheet with tooltip system and responsive layouts
- Improve accessibility with proper focus states and keyboard navigation
- Implement hover and active state transitions for enhanced UX
- Refactor expand button to use CSS classes instead of inline styles
- Update test files to reflect new class naming convention
2025-10-21 17:42:32 +08:00
Will Miao
49b9b7a5ea feat(lora): remove deprecated defaultInput, use only forceInput 2025-10-21 11:55:51 +08:00
Will Miao
9b1a9ee071 feat: refactor LoRA manager widget into top menu extension
- Rename ui_utils.js to top_menu_extension.js to better reflect functionality
- Replace custom button creation with ComfyUI Button and ButtonGroup components
- Implement proper top menu integration using ComfyUI's menu system
- Simplify window opening logic with shift-click support for new windows
- Add retry mechanism for attaching button to menu
- Improve code organization and maintainability by leveraging existing UI components
2025-10-21 11:54:50 +08:00
Will Miao
0b8f137a1b feat(i18n): update French translation for cleanup example images label 2025-10-19 22:33:32 +08:00
Will Miao
6148a12301 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-10-19 20:06:08 +08:00
Will Miao
fadbf21b4f fix(relink): keep sha untouched during relinking 2025-10-19 20:05:58 +08:00
pixelpaws
c38a06937d Merge pull request #578 from willmiao/codex/propose-solutions-for-missing-model_name, fixes #429
fix: guard model cache against missing metadata fields
2025-10-18 21:33:00 +08:00
pixelpaws
1a34403b0e fix(model-cache): avoid mutating raw entries without fields 2025-10-18 21:30:49 +08:00
pixelpaws
e4d58d0f60 fix(cache): harden metadata defaults 2025-10-18 21:19:09 +08:00
pixelpaws
4e4ea85cc3 Merge pull request #577 from willmiao/codex/add-sanitize_folder_name-utility-method, #552
fix: sanitize path template folder names
2025-10-18 16:48:43 +08:00
pixelpaws
f7a856349a fix(utils): sanitize path template folder names 2025-10-18 16:20:47 +08:00
Will Miao
15edd7a42c feat(settings): remove redundant option descriptions from layout settings 2025-10-18 09:52:47 +08:00
Will Miao
46243a236d feat(ui): refactor LoRA widget focus navigation
Refactor focus navigation logic in LoRA widget to separate focus queueing from execution. Added helper functions for finding focus entries and managing focus queue. This improves code organization and prevents focus issues when tabbing between strength and clip inputs.

Key changes:
- Extract focus navigation logic into reusable functions
- Separate focus queueing from focus execution
- Maintain same keyboard navigation behavior while improving code structure
- Fix potential focus loss when tabbing between inputs

The refactoring makes the focus navigation code more maintainable and reduces duplication while preserving the existing tab navigation functionality.
2025-10-18 09:26:33 +08:00
Will Miao
6f382e587a feat(loras): track pending focus target for strength inputs
Add pendingFocusTarget state to track which LoRA strength input is being interacted with. This ensures proper focus behavior when clicking on strength inputs, particularly when the widget is being re-rendered. The focus is now properly restored to the correct input after UI updates.
2025-10-18 08:49:12 +08:00
Will Miao
bf3d706bf4 feat(ui): add keyboard navigation for LoRA strength inputs, #432 2025-10-18 08:36:10 +08:00
Will Miao
cdf21e813c feat(settings): improve priority tags header layout and accessibility 2025-10-17 16:51:11 +08:00
Will Miao
10f5588e4a feat: add model_name and version_name placeholders to download paths, #552
Add support for {model_name} and {version_name} placeholders in download path templates. These new placeholders allow for more flexible and descriptive file organization by including the actual model name and version name in the download directory structure.

Changes include:
- Updated download_manager.py and utils.py to handle new placeholders
- Added placeholders to constants.js for UI reference
- Updated settings modal template to show available placeholders
- Added comprehensive tests to verify placeholder functionality

This enhancement provides users with more control over how downloaded models are organized on their file system.
2025-10-17 16:01:06 +08:00
Will Miao
0ecbdf6f39 feat(context-menu): prevent duplicate NSFW selector initialization
Add initialization tracking to prevent multiple event listener attachments
in context menu components. Use dataset.initialized flag to ensure NSFW
selector events are only set up once per component instance.

In ModelContextMenuMixin, replace DOM elements and reattach event listeners
to avoid duplicates when components are reinitialized. This fixes issues
where multiple click handlers could be attached to the same elements.
2025-10-17 10:52:02 +08:00
Will Miao
61101a7ad0 fix(recipe-scanner): honor SFW filtering option, fixes #576 2025-10-17 10:35:00 +08:00
Will Miao
6d9be814a5 feat(ui): add configurable model card footer action, fixes #249 2025-10-17 08:43:35 +08:00
pixelpaws
52bf93e430 Merge pull request #574 from willmiao/codex/add-model-name-display-setting
feat: respect model name display preference in model cache
2025-10-16 09:21:19 +08:00
pixelpaws
00fade756c fix(settings): dispatch name display updates on original loop 2025-10-16 09:02:35 +08:00
pixelpaws
3c0feb23ba feat(model-cache): respect model name display preference 2025-10-16 07:01:04 +08:00
Will Miao
3627840fe9 feat: update portable package download link to v0.9.8 2025-10-15 21:19:15 +08:00
pixelpaws
bbdc1bba87 Merge pull request #573 from willmiao/codex/add-batch-model-version-retrieval
feat: batch model update refresh using Civitai bulk API
2025-10-15 20:55:53 +08:00
pixelpaws
21a1bc1a01 feat(metadata): batch refresh model versions 2025-10-15 20:47:30 +08:00
Will Miao
0968698804 feat: add v0.9.8 release notes and update version 2025-10-15 19:47:13 +08:00
Will Miao
a5b2e9b0bf feat: add update service dependency and has_update filter
- Pass ModelUpdateService to CheckpointService, EmbeddingService, and LoraService constructors
- Add has_update query parameter filter to model listing handler
- Update BaseModelService to accept optional update_service parameter

These changes enable model update functionality across different model types and provide filtering capability for models with available updates.
2025-10-15 17:25:16 +08:00
pixelpaws
5a6ff444b9 Merge pull request #572 from willmiao/codex/design-ui-for-model-update-notifications
refactor: tighten civitai update endpoints
2025-10-15 16:01:20 +08:00
pixelpaws
3bb240d3c1 fix(updates): avoid caching failed civitai lookups 2025-10-15 16:00:23 +08:00
pixelpaws
ee0d241c75 refactor(routes): limit update endpoints to essentials 2025-10-15 15:37:35 +08:00
Will Miao
321ff72953 feat: remove delay from bulk auto-organize completion 2025-10-15 10:32:59 +08:00
Will Miao
412f1e62a1 feat(i18n): add model name display option and improve localization, fixes #440
- Add new model name display setting with options to show model name or file name
- Implement helper function to determine display name based on user preference
- Update model card footer to use dynamic display name
- Include model name display setting in settings modal and state management
- Remove redundant labels from display density descriptions in multiple locales
- Simplify card info display descriptions by removing duplicate text

The changes provide cleaner UI text and add flexibility for users to choose between displaying model names or file names in card footers.
2025-10-15 10:23:39 +08:00
Will Miao
8901b32a55 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-10-15 09:19:05 +08:00
Will Miao
8ab6cc72ad feat: add project documentation in IFLOW.md 2025-10-15 09:18:59 +08:00
pixelpaws
52e671638b feat(example-images): add stop control for download panel 2025-10-15 08:46:03 +08:00
Will Miao
a3070f8d82 feat: add rate limit error handling to CivArchive client
- Add RateLimitError import and exception handling in API methods
- Create _make_request wrapper to surface rate limit errors from downloader
- Add test case to verify rate limit error propagation
- Set default provider as "civarchive_api" for rate limit errors

This ensures rate limit errors are properly propagated and handled throughout the CivArchive client, improving error reporting and allowing callers to implement appropriate retry logic.
2025-10-14 21:38:24 +08:00
Will Miao
3fde474583 feat(civitai): add rate limiting support and error handling
- Add RateLimitError import and _make_request wrapper method to handle rate limiting
- Update API methods to use _make_request wrapper instead of direct downloader calls
- Add explicit RateLimitError handling in API methods to properly propagate rate limit errors
- Add _extract_retry_after method to parse Retry-After headers
- Improve error handling by surfacing rate limit information to callers

These changes ensure that rate limiting from the Civitai API is properly detected and handled, allowing callers to implement appropriate backoff strategies when rate limits are encountered.
2025-10-14 21:38:24 +08:00
Will Miao
1454991d6d feat(i18n): update bulk action labels to reflect selected items
Change bulk action labels from "All" to "Selected" in both English and Chinese locales to accurately reflect that these actions apply only to selected items rather than all items. This improves user interface clarity and prevents potential confusion about the scope of bulk operations.
2025-10-14 21:36:11 +08:00
Will Miao
4398851bb9 feat: reorder and update context menu items
- Remove 'clear' action from context menu
- Reorder menu items to prioritize common operations
- Move destructive operations (delete, move) to bottom section
- Add visual separation between action groups
- Maintain all existing functionality with improved organization
2025-10-14 21:29:09 +08:00
Will Miao
5173aa6c20 feat(model-scanner): add logging for file processing, fixes #566 2025-10-14 19:44:59 +08:00
Will Miao
3d98572a62 feat: improve civitai data handling and type safety, fixes #565
- Replace setdefault with get and explicit dict initialization in MetadataUpdater
- Change civitai field type from Optional[Dict] to Dict[str, Any] with default_factory
- Add None check and dict initialization in BaseModelMetadata.__post_init__
- Ensures civitai data is always a dictionary, preventing type errors and improving code reliability
2025-10-14 16:03:33 +08:00
Will Miao
c48095d9c6 feat: replace IO type imports with string literals
Remove direct imports of IO type constants from comfy.comfy_types and replace them with string literals "STRING" in input type definitions and return types. This improves code portability and reduces dependency on external type definitions.

Changes made across multiple files:
- Remove `from comfy.comfy_types import IO` imports
- Replace `IO.STRING` with "STRING" in INPUT_TYPES and RETURN_TYPES
- Move CLIPTextEncode import to function scope in prompt.py for better dependency management

This refactor maintains the same functionality while making the code more self-contained and reducing external dependencies.
2025-10-14 09:12:55 +08:00
Will Miao
1e4d1b8f15 feat(nodes): add Promp (LoraManager) node and autocomplete support 2025-10-13 23:23:32 +08:00
pixelpaws
8c037465ba Merge pull request #564 from willmiao/codex/design-apis-for-pause-and-resume-download
test: add coverage for download pause and resume controls
2025-10-13 21:39:47 +08:00
pixelpaws
055c1ca0d4 test(downloads): cover pause and resume flows 2025-10-13 21:30:23 +08:00
Will Miao
27370df93a feat(download): add support to download models from civarchive, fixes #381 2025-10-13 19:27:56 +08:00
Will Miao
60d23aa238 feat(download): enhance download progress ui with transfer stats 2025-10-13 19:06:51 +08:00
pixelpaws
5e441d9c4f Merge pull request #563 from willmiao/codex/add-download-speed-info-to-progress
feat(downloads): expose throughput metrics in progress APIs
2025-10-13 18:11:32 +08:00
pixelpaws
eb76468280 feat(downloads): expose throughput metrics in progress APIs 2025-10-13 14:39:31 +08:00
Will Miao
01bbaa31a8 fix(ModelTags): fix performance and UX issues in ModelTags 2025-10-12 22:31:10 +08:00
Will Miao
bddf023dc4 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-10-12 17:43:31 +08:00
Will Miao
8e69a247ed feat(i18n): Update priority tags translations for better localization 2025-10-12 17:43:26 +08:00
pixelpaws
97141b01e1 Merge pull request #562 from willmiao/incremental-cache, see #561
feat(model-scanner): add metadata tracking and improve cache management
2025-10-12 17:09:19 +08:00
Will Miao
acf610ddff feat(model-scanner): add metadata tracking and improve cache management
- Add metadata_source field to track origin of model metadata
- Define MODEL_COLUMNS constants for consistent column management
- Refactor SQL queries to use dynamic column selection
- Improve Civitai data detection to include creator_username and trained_words
- Update database operations to handle new metadata field and tag management
2025-10-12 16:54:39 +08:00
Will Miao
a9a6f66035 feat(api): enhance model API creation with validation and default fallback
Refactored `createModelApiClient` to pass the specific model type as a parameter to each client constructor. Introduced `isValidModelType` for validation and added logic to set a default model type if provided type is invalid or not specified. Updated `getModelApiClient` function to utilize these improvements, ensuring robust model API instantiation.
2025-10-12 15:28:30 +08:00
Will Miao
0040863a03 feat(tests): introduce ROUTE_CALLS_KEY for organizing route calls
Addressed the aiohttp warnings by aligning the test scaffolding with current best practices. Added an AppKey constant and stored the route tracking list through it to satisfy aiohttp’s NotAppKeyWarning expectations. Swapped the websocket lambdas for async no-op handlers so the registered routes now point to coroutine callables, clearing the deprecation warning about bare functions.
2025-10-12 09:12:57 +08:00
Will Miao
4ab86b4ae2 feat(locale): add drag drop error message in locales 2025-10-12 09:07:36 +08:00
Will Miao
b32b4b4042 feat: enhance model scanning to include creator username
Updated the `ModelScanner` class to extract and format the creator username from Civitai data. This enhancement ensures that the creator information is properly included in slim model data.
2025-10-12 08:51:42 +08:00
Will Miao
4e552dcf3e feat: Add drag-and-drop support with visual feedback for sidebar nodes
This commit implements drag-and-drop functionality for sidebar nodes,
adding visual feedback via highlight styling when dragging over
valid drop targets. The CSS introduces new classes to style
drop indicators using the lora-accent color scheme, while the JS
adds event handlers to manage drag operations and update the UI
state accordingly. This improves user interaction by providing
clear visual cues for valid drop areas during file operations.
2025-10-12 06:55:01 +08:00
Will Miao
8f4c02efdc Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-10-12 05:44:07 +08:00
Will Miao
b77c596f3a Fix error message to improve clarity in DownloadManager 2025-10-12 05:43:59 +08:00
pixelpaws
181f0b5626 Merge pull request #560 from willmiao/codex/analyze-coverage-for-backend-tests
test: add standalone bootstrap and model factory coverage
2025-10-11 22:48:35 +08:00
pixelpaws
480e5d966f test: add standalone bootstrap and model factory coverage 2025-10-11 22:42:24 +08:00
Will Miao
e8636b949d feat(ModelTags): Implemented drag-and-drop reordering for tag edit mode so users can rearrange tags directly in the UI, fixes #414 2025-10-11 20:56:22 +08:00
pixelpaws
8ea369db47 Merge pull request #559 from willmiao/codex/add-info-level-logging-in-fetch_and_update_model
feat: log metadata channel on metadata fetch
2025-10-11 20:37:08 +08:00
Will Miao
ec9b37eb53 feat: add model type context to tag suggestions
- Pass modelType parameter to setupTagEditMode function
- Implement model type aware priority tag suggestions
- Add model type normalization and resolution logic
- Handle suggestion state reset when model type changes
- Maintain backward compatibility with existing functionality

The changes enable context-aware tag suggestions based on model type, improving tag relevance and user experience when editing tags for different model types.
2025-10-11 20:36:38 +08:00
Will Miao
b0847f6b87 feat(doc): update priority tags configuration guide wiki 2025-10-11 20:08:42 +08:00
pixelpaws
84d10b1f3b feat(metadata): log metadata channel on fetch 2025-10-11 20:07:01 +08:00
Will Miao
4fdc97d062 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-10-11 19:43:41 +08:00
Will Miao
5fe5e7ea54 feat(ui): enhance settings modal styling and add priority tags tabs
- Rename `.settings-open-location-button` to `.settings-action-link` for better semantic meaning
- Add enhanced hover/focus states with accent colors and border transitions
- Implement tabbed interface for priority tags with LoRA, checkpoint, and embedding sections
- Improve input styling with consistent error states and example code formatting
- Remove deprecated grid layout in favor of tab-based organization
- Add responsive tab navigation with proper focus management and visual feedback
2025-10-11 19:43:22 +08:00
pixelpaws
7be1a2bd65 Merge pull request #557 from willmiao/civarc-api-support
CivArchive API support
2025-10-11 18:10:06 +08:00
pixelpaws
87842385c6 Merge pull request #558 from willmiao/codex/design-custom-priority-tags-format
feat: add customizable priority tags
2025-10-11 17:46:13 +08:00
Will Miao
1dc189eb39 feat(metadata): implement fallback provider strategy for deleted models
Refactor metadata sync service to use a prioritized provider fallback system when handling deleted CivitAI models. The new approach:

1. Attempts civarchive_api provider first for deleted models
2. Falls back to sqlite provider if archive DB is enabled
3. Maintains existing default provider behavior for non-deleted models
4. Tracks provider attempts and errors for better debugging

This improves reliability when fetching metadata for deleted models by trying multiple sources before giving up, and provides clearer error messages based on which providers were attempted.
2025-10-11 17:44:38 +08:00
pixelpaws
6120922204 chore(priority-tags): add newline terminator 2025-10-11 17:38:20 +08:00
Will Miao
ddb30dbb17 Revert "feat(civarchive_client): update get_model_version_info to resolve the real model/version IDs before fetching the target metadata."
This reverts commit c3a66ecf28.
2025-10-11 16:11:17 +08:00
Will Miao
1e8bd88e28 feat(metadata): improve model ID redirect logic and provider ordering
- Fix CivArchive model ID redirect logic to only follow redirects when context points to original model
- Rename CivitaiModelMetadataProvider to CivArchiveModelMetadataProvider for consistency
- Reorder fallback metadata providers to prioritize Civitai API over CivArchive API for better metadata quality
- Remove unused asyncio import and redundant logging from metadata sync service
2025-10-11 16:11:13 +08:00
Will Miao
c3a66ecf28 feat(civarchive_client): update get_model_version_info to resolve the real model/version IDs before fetching the target metadata. 2025-10-11 15:07:42 +08:00
Will Miao
1f60160e8b feat(civarchive_client): enhance request handling and context parsing
Introduce `_request_json` method for async JSON requests and improved error handling. Add static methods `_normalize_payload`, `_split_context`, `_ensure_list`, and `_build_model_info` to parse and normalize API responses. These changes improve the robustness of the CivArchiveClient by ensuring consistent data structures and handling potential API response issues gracefully.
2025-10-11 13:07:29 +08:00
Will Miao
7d560bf07a chore: add refs 2025-10-11 12:59:13 +08:00
Will Miao
47da9949d9 feat: update recipe download URL path to include /lm prefix
Update the download URL path in RecipeSharingService to include '/lm' prefix,
aligning with the new API route structure for recipe sharing endpoints.
2025-10-10 21:46:56 +08:00
scruffynerf
68c0a5ba71 Better Civ Archive support (adds API) (#549)
* add CivArchive API

* Oops, missed committing this part when I updated codebase to latest version

* Adjust API for version fetching and solve the broken API (hash gives only files, not models - likely to be fixed but in the meantime...)

* add asyncio import to allow timeout cooldown

---------

Co-authored-by: Scruffy Nerf <Scruffynerf@duck.com>
2025-10-10 20:04:01 +08:00
pixelpaws
1aa81c803b Merge pull request #551 from willmiao/codex/refactor-model-metadata-saving-logic
fix: hydrate cached metadata before persisting updates
2025-10-10 08:56:12 +08:00
pixelpaws
8f5e134d3e fix: skip redundant hydration in metadata sync service 2025-10-10 08:49:54 +08:00
pixelpaws
ef03a2a917 fix(metadata): hydrate cached records before saving 2025-10-10 08:30:51 +08:00
Will Miao
e275968553 feat(civitai): remove debug print statement from rewrite_preview_url function 2025-10-10 08:18:23 +08:00
Will Miao
76d3aa2b5b feat(version): bump version to 0.9.7 in pyproject.toml 2025-10-09 22:15:50 +08:00
Will Miao
c9a65c7347 feat(metadata): implement model data hydration and enhance metadata handling across services, fixes #547 2025-10-09 22:15:07 +08:00
Will Miao
f542ade628 feat(civitai): implement URL rewriting for Civitai previews and enhance download handling, fixes #499 2025-10-09 17:54:37 +08:00
Will Miao
d2c2bfbe6a feat(sidebar): add recursive search functionality and toggle button 2025-10-09 17:07:10 +08:00
Will Miao
2b6910bd55 feat(misc): mark model versions in library for Civitai user models 2025-10-09 15:23:42 +08:00
Will Miao
b1dd733493 feat(civitai): enhance model version handling with cache lookup 2025-10-09 14:10:00 +08:00
pixelpaws
5dcf0a1e48 Merge pull request #545 from willmiao/codex/evaluate-sqlite-cache-indexing-necessity
feat: index cached models by version id
2025-10-09 13:54:46 +08:00
pixelpaws
cf357b57fc feat(scanner): index cached models by version id 2025-10-09 13:50:44 +08:00
pixelpaws
4e1773833f Merge pull request #544 from willmiao/codex/add-endpoint-to-fetch-civitai-user-models
Add endpoint to fetch Civitai user models
2025-10-09 11:56:57 +08:00
pixelpaws
8cf762ffd3 feat(misc): add civitai user model lookup 2025-10-09 11:49:41 +08:00
pixelpaws
d997eaa429 Merge pull request #543 from willmiao/codex/refactor-get_model_version-logic-and-add-tests, fixes #540
fix: improve Civitai model version retrieval
2025-10-09 11:07:33 +08:00
pixelpaws
8e51f0f19f fix(civitai): improve model version retrieval 2025-10-09 10:56:25 +08:00
pixelpaws
f0e246b4ac Merge pull request #542 from willmiao/codex/investigate-backend-tests-modifying-settings.json
Refactor settings manager to lazy singleton
2025-10-08 16:02:11 +08:00
pixelpaws
a232997a79 fix(utils): respect metadata sync overrides 2025-10-08 15:52:15 +08:00
pixelpaws
08a449db99 fix(metadata): refresh metadata sync settings 2025-10-08 10:38:05 +08:00
pixelpaws
0c023c9888 fix(settings): lazily resolve module aliases 2025-10-08 10:10:23 +08:00
pixelpaws
0ad92d00b3 fix(settings): restore legacy settings aliases 2025-10-08 09:54:36 +08:00
pixelpaws
a726cbea1e fix(routes): pass resolved settings to metadata sync 2025-10-08 09:32:57 +08:00
pixelpaws
c53fa8692b refactor(settings): lazily initialize manager 2025-10-08 08:56:57 +08:00
Will Miao
3118f3b43c feat(graph): enhance node handling with graph identifiers and improve metadata updates, see #408, #538 2025-10-07 23:22:38 +08:00
227 changed files with 24577 additions and 3143 deletions

4
.github/FUNDING.yml vendored
View File

@@ -1,5 +1,5 @@
# These are supported funding model platforms
patreon: PixelPawsAI
ko_fi: pixelpawsai
custom: ['paypal.me/pixelpawsai']
patreon: PixelPawsAI
custom: ['paypal.me/pixelpawsai', 'https://afdian.com/a/pixelpawsai']

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ civitai/
node_modules/
coverage/
.coverage
model_cache/

103
IFLOW.md Normal file
View File

@@ -0,0 +1,103 @@
# ComfyUI LoRA Manager - iFlow 上下文
## 项目概述
ComfyUI LoRA Manager 是一个全面的工具集,用于简化 ComfyUI 中 LoRA 模型的组织、下载和应用。它提供了强大的功能,如配方管理、检查点组织和一键工作流集成,使模型操作更快、更流畅、更简单。
该项目是一个 Python 后端与 JavaScript 前端结合的 Web 应用程序,既可以作为 ComfyUI 的自定义节点运行,也可以作为独立应用程序运行。
## 项目结构
```
D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\
├── py/ # Python 后端代码
│ ├── config.py # 全局配置
│ ├── lora_manager.py # 主入口点
│ ├── controllers/ # 控制器
│ ├── metadata_collector/ # 元数据收集器
│ ├── middleware/ # 中间件
│ ├── nodes/ # ComfyUI 节点
│ ├── recipes/ # 配方相关
│ ├── routes/ # API 路由
│ ├── services/ # 业务逻辑服务
│ ├── utils/ # 工具函数
│ └── validators/ # 验证器
├── static/ # 静态资源 (CSS, JS, 图片)
├── templates/ # HTML 模板
├── locales/ # 国际化文件
├── tests/ # 测试代码
├── standalone.py # 独立模式入口
├── requirements.txt # Python 依赖
├── package.json # Node.js 依赖和脚本
└── README.md # 项目说明
```
## 核心组件
### 后端 (Python)
- **主入口**: `py/lora_manager.py``standalone.py`
- **配置**: `py/config.py` 管理全局配置和路径
- **路由**: `py/routes/` 目录下包含各种 API 路由
- **服务**: `py/services/` 目录下包含业务逻辑,如模型扫描、下载管理等
- **模型管理**: 使用 `ModelServiceFactory` 来管理不同类型的模型 (LoRA, Checkpoint, Embedding)
### 前端 (JavaScript)
- **构建工具**: 使用 Node.js 和 npm 进行依赖管理和测试
- **测试**: 使用 Vitest 进行前端测试
## 构建和运行
### 安装依赖
```bash
# Python 依赖
pip install -r requirements.txt
# Node.js 依赖 (用于测试)
npm install
```
### 运行 (ComfyUI 模式)
作为 ComfyUI 的自定义节点安装后,在 ComfyUI 中启动即可。
### 运行 (独立模式)
```bash
# 使用默认配置运行
python standalone.py
# 指定主机和端口
python standalone.py --host 127.0.0.1 --port 9000
```
### 测试
#### 后端测试
```bash
# 安装开发依赖
pip install -r requirements-dev.txt
# 运行测试
pytest
```
#### 前端测试
```bash
# 运行测试
npm run test
# 运行测试并生成覆盖率报告
npm run test:coverage
```
## 开发约定
- **代码风格**: Python 代码应遵循 PEP 8 规范
- **测试**: 新功能应包含相应的单元测试
- **配置**: 使用 `settings.json` 文件进行用户配置
- **日志**: 使用 Python 标准库 `logging` 模块进行日志记录

View File

@@ -34,15 +34,24 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes
### v0.9.6
* **Critical Performance Optimization** - Introduced persistent model cache that dramatically accelerates initialization after startup and significantly reduces Python backend memory footprint for improved application performance.
* **Cross-Browser Settings Synchronization** - Migrated nearly all settings to the backend, ensuring your preferences sync automatically across all browsers for a seamless multi-browser experience.
* **Protected User Settings Location** - Relocated user settings (settings.json) to the user config directory (accessible via the link icon in Settings), preventing accidental deletion during reinstalls or updates.
* **Global Context Menu** - Added a new global context menu accessible by right-clicking on empty page areas, providing quick access to global operations with more features coming in future updates.
* **Multi-Library Support** - Introduced support for managing multiple libraries, allowing you to easily switch between different model collections (advanced usage, documentation in progress).
* **Bug Fixes & Stability Improvements** - Various bug fixes and enhancements for improved stability and reliability.
### v0.9.9
* **Check for Updates Feature** - Users can now check for updates for all models or selected models in bulk mode. Models with available updates will display an "update available" badge on their model card, and users can filter to show only models with updates.
* **Model Versions Management** - Added a new Versions tab in the model modal that centralizes all versions of a model, providing download, delete, and ignore update functions.
* **Send Checkpoint to ComfyUI** - Users can now click the send button on a checkpoint card to send the checkpoint directly to the current workflow's checkpoint or diffusion model loader node in ComfyUI.
* **Customizable Model Card Display** - Added a new setting that allows users to choose whether to display the model name or filename on model cards.
* **New Path Template Placeholders** - Added new path template placeholders: `{model_name}` and `{version_name}` for more flexible organization.
* **ComfyUI Auto Path Correction Setting** - Added a new setting within ComfyUI to enable or disable the auto path correction feature.
### v0.9.3
### v0.9.8
* **Full CivArchive API Support** - Added complete support for the CivArchive API as a fallback metadata source beyond Civitai API. Models deleted from Civitai can now still retrieve metadata through the CivArchive API.
* **Download Models from CivArchive** - Added support for downloading models directly from CivArchive, similar to downloading from Civitai. Simply click the Download button and paste the model URL to download the corresponding model.
* **Custom Priority Tags** - Introduced Custom Priority Tags feature, allowing users to define custom priority tags. These tags will appear as suggestions when editing tags or during auto organization/download using default paths, providing more precise and controlled folder organization. [Guide](https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide)
* **Drag and Drop Tag Reordering** - Added drag and drop functionality to reorder tags in the tags edit mode for improved usability.
* **Download Control in Example Images Panel** - Added stop control in the Download Example Images Panel for better download management.
* **Prompt (LoraManager) Node with Autocomplete** - Added new Prompt (LoraManager) node with autocomplete feature for adding embeddings.
* **Lora Manager Nodes in Subgraphs** - Lora Manager nodes now support being placed within subgraphs for more flexible workflow organization.
### v0.9.6
* **Metadata Archive Database Support** - Added the ability to download and utilize a metadata archive database, enabling access to metadata for models that have been deleted from CivitAI.
* **App-Level Proxy Settings** - Introduced support for configuring a global proxy within the application, making it easier to use the manager behind network restrictions.
* **Bug Fixes** - Various bug fixes for improved stability and reliability.
@@ -147,9 +156,10 @@ Enhance your Civitai browsing experience with our companion browser extension! S
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.2/lora_manager_portable.7z)
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.9.8/lora_manager_portable.7z)
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder.
3. Edit the new `settings.json` to include your correct model folder paths and CivitAI API key
- Set `"use_portable_settings": true` if you want the configuration to remain inside the repository folder instead of your user settings directory.
4. Run run.bat
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
@@ -230,8 +240,9 @@ You can now run LoRA Manager independently from ComfyUI:
```
2. **For non-ComfyUI users**:
- Copy the provided `settings.json.example` file to create a new file named `settings.json`
- Edit `settings.json` to include your correct model folder paths and CivitAI API key
- Copy the provided `settings.json.example` file to create a new file named `settings.json`. Update the API key, optional language, and folder paths only—the library registry is created automatically when LoRA Manager starts.
- Edit `settings.json` to include your correct model folder paths and CivitAI API key (you can leave the defaults until ready to configure them)
- Enable portable mode by setting `"use_portable_settings": true` if you prefer LoRA Manager to read and write the `settings.json` located in the project directory.
- Install required dependencies: `pip install -r requirements.txt`
- Run standalone mode:
```bash

View File

@@ -2,6 +2,7 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.prompt import PromptLoraManager
from .py.nodes.lora_stacker import LoraStacker
from .py.nodes.save_image import SaveImage
from .py.nodes.debug_metadata import DebugMetadata
@@ -17,6 +18,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
if str(package_root) not in sys.path:
sys.path.append(str(package_root))
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
@@ -29,6 +31,7 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = {
PromptLoraManager.NAME: PromptLoraManager,
LoraManagerLoader.NAME: LoraManagerLoader,
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
TriggerWordToggle.NAME: TriggerWordToggle,

View File

@@ -0,0 +1,46 @@
# Custom Priority Tag Format Proposal
To support user-defined priority tags with flexible aliasing across different model types, the configuration will be stored as editable strings. The format balances readability with enough structure for parsing on both the backend and frontend.
## Format Overview
- Each model type is declared on its own line: `model_type: entries`.
- Entries are comma-separated and ordered by priority from highest to lowest.
- An entry may be a single canonical tag (e.g., `realistic`) or a canonical tag with aliases.
- Canonical tags define the final folder name that should be used when matching that entry.
- Aliases are enclosed in parentheses and separated by `|` (vertical bar).
- All matching is case-insensitive; stored canonical names preserve the user-specified casing for folder creation and UI suggestions.
### Grammar
```
priority-config := model-config { "\n" model-config }
model-config := model-type ":" entry-list
model-type := <identifier without spaces>
entry-list := entry { "," entry }
entry := canonical [ "(" alias { "|" alias } ")" ]
canonical := <tag text without parentheses or commas>
alias := <tag text without parentheses, commas, or pipes>
```
Examples:
```
lora: celebrity(celeb|celebrity), stylized, character(char)
checkpoint: realistic(realism|realistic), anime(anime-style|toon)
embedding: face, celeb(celebrity|celeb)
```
## Parsing Notes
- Whitespace around separators is ignored to make manual editing more forgiving.
- Duplicate canonical tags within the same model type collapse to a single entry; the first definition wins.
- Aliases map to their canonical tag. When generating folder names, the canonical form is used.
- Tags that do not match any alias or canonical entry fall back to the first tag in the model's tag list, preserving current behavior.
## Usage
- **Backend:** Convert each model type's string into an ordered list of canonical tags with alias sets. During path generation, iterate by priority order and match tags against both canonical names and their aliases.
- **Frontend:** Surface canonical tags as suggestions, optionally displaying aliases in tooltips or secondary text. Input validation should warn about duplicate aliases within the same model type.
This format allows users to customize priority tag handling per model type while keeping editing simple and avoiding proliferation of folder names through alias normalization.

View File

@@ -0,0 +1,71 @@
# Priority Tags Configuration Guide
This guide explains how to tailor the tag priority order that powers folder naming and tag suggestions in the LoRA Manager. You only need to edit the comma-separated list of entries shown in the **Priority Tags** field for each model type.
## 1. Pick the Model Type
In the **Priority Tags** dialog you will find one tab per model type (LoRA, Checkpoint, Embedding). Select the tab you want to update; changes on one tab do not affect the others.
## 2. Edit the Entry List
Inside the textarea you will see a line similar to:
```
character, concept, style(toon|toon_style)
```
This entire line is the **entry list**. Replace it with your own ordered list.
### Entry Rules
Each entry is separated by a comma, in order from highest to lowest priority:
- **Canonical tag only:** `realistic`
- **Canonical tag with aliases:** `character(char|chars)`
Aliases live inside `()` and are separated with `|`. The canonical name is what appears in folder names and UI suggestions when any of the aliases are detected. Matching is case-insensitive.
## Use `{first_tag}` in Path Templates
When your path template contains `{first_tag}`, the app picks a folder name based on your priority list and the models own tags:
- It checks the priority list from top to bottom. If a canonical tag or any of its aliases appear in the model tags, that canonical name becomes the folder name.
- If no priority tags are found but the model has tags, the very first model tag is used.
- If the model has no tags at all, the folder falls back to `no tags`.
### Example
With a template like `/{model_type}/{first_tag}` and the priority entry list `character(char|chars), style(anime|toon)`:
| Model Tags | Folder Name | Why |
| --- | --- | --- |
| `["chars", "female"]` | `character` | `chars` matches the `character` alias, so the canonical wins. |
| `["anime", "portrait"]` | `style` | `anime` hits the `style` entry, so its canonical label is used. |
| `["portrait", "bw"]` | `portrait` | No priority match, so the first model tag is used. |
| `[]` | `no tags` | Nothing to match, so the fallback is applied. |
## 3. Save the Settings
After editing the entry list, press **Enter** to save. Use **Shift+Enter** whenever you need a new line. Clicking outside the field also saves automatically. A success toast confirms the update.
## Examples
| Goal | Entry List |
| --- | --- |
| Prefer people over styles | `character, portraits, style(anime\|toon)` |
| Group sci-fi variants | `sci-fi(scifi\|science_fiction), cyberpunk(cyber\|punk)` |
| Alias shorthand tags | `realistic(real\|realisim), photorealistic(photo_real)` |
## Tips
- Keep canonical names short and meaningful—they become folder names.
- Place the most important categories first; the first match wins.
- Avoid duplicate canonical names within the same list; only the first instance is used.
## Troubleshooting
- **Unexpected folder name?** Check that the canonical name you want is placed before other matches.
- **Alias not working?** Ensure the alias is inside parentheses and separated with `|`, e.g. `character(char|chars)`.
- **Validation error?** Look for missing parentheses or stray commas. Each entry must follow the `canonical(alias|alias)` pattern or just `canonical`.
With these basics you can quickly adapt Priority Tags to match your librarys organization style.

View File

@@ -0,0 +1,26 @@
# Backend Test Coverage Notes
## Pytest Execution
- Command: `python -m pytest`
- Result: All 283 collected tests passed in the current environment.
- Coverage tooling (``pytest-cov``/``coverage``) is unavailable in the offline sandbox, so line-level metrics could not be generated. The earlier attempt to install ``pytest-cov`` failed because the package index cannot be reached from the container.
## High-Priority Gaps to Address
### 1. Standalone server bootstrapping
* **Source:** [`standalone.py`](../../standalone.py)
* **Why it matters:** The standalone entry point wires together the aiohttp application, static asset routes, model-route registration, and configuration validation. None of these behaviours are covered by automated tests, leaving regressions in bootstrapping logic undetected.
* **Suggested coverage:** Add integration-style tests that instantiate `StandaloneServer`/`StandaloneLoraManager` with temporary settings and assert that routes (HTTP + websocket) are registered, configuration warnings fire for missing paths, and the mock ComfyUI shims behave as expected.
### 2. Model service registration factory
* **Source:** [`py/services/model_service_factory.py`](../../py/services/model_service_factory.py)
* **Why it matters:** The factory coordinates which model services and routes the API exposes, including error handling when unknown model types are requested. No current tests verify registration, memoization of route instances, or the logging path on failures.
* **Suggested coverage:** Unit tests that exercise `register_model_type`, `get_route_instance`, error branches in `get_service_class`/`get_route_class`, and `setup_all_routes` when a route setup raises. Use lightweight fakes to confirm the logger is called and state is cleared via `clear_registrations`.
### 3. Server-side i18n helper
* **Source:** [`py/services/server_i18n.py`](../../py/services/server_i18n.py)
* **Why it matters:** Template rendering relies on the `ServerI18nManager` to load locale JSON, perform key lookups, and format parameters. The fallback logic (dot-notation lookup, English fallbacks, placeholder substitution) is untested, so malformed locale files or regressions in placeholder handling would slip through.
* **Suggested coverage:** Tests that load fixture locale dictionaries, assert `set_locale` fallbacks, verify nested key resolution and placeholder substitution, and ensure missing keys return the original identifier.
## Next Steps
Prioritize creating focused unit tests around these modules, then re-run pytest once coverage tooling is available to confirm the new tests close the identified gaps.

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Bytes",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "Checkpoint-Name kopiert",
"toggleBlur": "Unschärfe umschalten",
"show": "Anzeigen",
"openExampleImages": "Beispielbilder-Ordner öffnen"
"openExampleImages": "Beispielbilder-Ordner öffnen",
"replacePreview": "Vorschau ersetzen",
"copyCheckpointName": "Checkpoint-Name kopieren",
"copyEmbeddingName": "Embedding-Name kopieren",
"sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden"
},
"nsfw": {
"matureContent": "Nicht jugendfreie Inhalte",
@@ -115,12 +120,17 @@
"updateFailed": "Fehler beim Aktualisieren des Favoriten-Status"
},
"sendToWorkflow": {
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert"
"checkpointNotImplemented": "Checkpoint an Workflow senden - Funktion wird implementiert",
"missingPath": "Modellpfad für diese Karte konnte nicht ermittelt werden"
},
"exampleImages": {
"checkError": "Fehler beim Überprüfen der Beispielbilder",
"missingHash": "Fehlende Modell-Hash-Informationen.",
"noRemoteImagesAvailable": "Keine Remote-Beispielbilder für dieses Modell auf Civitai verfügbar"
},
"badges": {
"update": "Update",
"updateAvailable": "Update verfügbar"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "Bitte legen Sie einen Speicherort fest, bevor Sie Beispielbilder herunterladen.",
"unavailable": "Beispielbild-Downloads sind noch nicht verfügbar. Versuchen Sie es erneut, nachdem die Seite vollständig geladen ist."
},
"checkModelUpdates": {
"label": "Auf Updates prüfen",
"loading": "Prüfe auf {type}-Updates...",
"success": "{count} Update(s) für {type} gefunden",
"none": "Alle {type} sind auf dem neuesten Stand",
"error": "Fehler beim Prüfen auf {type}-Updates: {message}"
},
"cleanupExampleImages": {
"label": "Beispielbild-Ordner bereinigen",
"success": "{count} Ordner wurden in den Papierkorb verschoben",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "Updates prüfen",
"notifications": "Benachrichtigungen",
"support": "Unterstützung"
}
},
@@ -199,6 +217,7 @@
"videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen",
"folderSettings": "Ordner-Einstellungen",
"priorityTags": "Prioritäts-Tags",
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"exampleImages": "Beispielbilder",
"misc": "Verschiedenes",
@@ -224,21 +243,32 @@
},
"displayDensityHelp": "Wählen Sie, wie viele Karten pro Zeile angezeigt werden sollen:",
"displayDensityDetails": {
"default": "Standard: 5 (1080p), 6 (2K), 8 (4K)",
"medium": "Mittel: 6 (1080p), 7 (2K), 9 (4K)",
"compact": "Kompakt: 7 (1080p), 8 (2K), 10 (4K)"
"default": "5 (1080p), 6 (2K), 8 (4K)",
"medium": "6 (1080p), 7 (2K), 9 (4K)",
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
"showFolderSidebar": "Ordner-Seitenleiste anzeigen",
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
"cardInfoDisplay": "Karten-Info-Anzeige",
"cardInfoDisplayOptions": {
"always": "Immer sichtbar",
"hover": "Bei Hover anzeigen"
},
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen:",
"cardInfoDisplayDetails": {
"always": "Immer sichtbar: Kopf- und Fußzeilen sind immer sichtbar",
"hover": "Bei Hover anzeigen: Kopf- und Fußzeilen erscheinen nur beim Darüberfahren mit der Maus"
}
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
"modelCardFooterActionOptions": {
"exampleImages": "Beispielbilder öffnen",
"replacePreview": "Vorschau ersetzen"
},
"modelCardFooterActionHelp": "Wähle aus, was die Schaltfläche unten rechts auf der Karte ausführt",
"modelNameDisplay": "Anzeige des Modellnamens",
"modelNameDisplayOptions": {
"modelName": "Modellname",
"fileName": "Dateiname"
},
"modelNameDisplayHelp": "Wählen Sie aus, was in der Fußzeile der Modellkarte angezeigt werden soll"
},
"folderSettings": {
"activeLibrary": "Aktive Bibliothek",
@@ -253,6 +283,26 @@
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"noDefault": "Kein Standard"
},
"priorityTags": {
"title": "Prioritäts-Tags",
"description": "Passen Sie die Tag-Prioritätsreihenfolge für jeden Modelltyp an (z. B. character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "Prioritäts-Tags-Hilfe öffnen",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "Prioritäts-Tags aktualisiert.",
"saveError": "Prioritäts-Tags konnten nicht aktualisiert werden.",
"loadingSuggestions": "Lade Vorschläge...",
"validation": {
"missingClosingParen": "Eintrag {index} fehlt eine schließende Klammer.",
"missingCanonical": "Eintrag {index} muss einen kanonischen Tag-Namen enthalten.",
"duplicateCanonical": "Der kanonische Tag \"{tag}\" kommt mehrfach vor.",
"unknown": "Ungültige Prioritäts-Tag-Konfiguration."
}
},
"downloadPathTemplates": {
"title": "Download-Pfad-Vorlagen",
"help": "Konfigurieren Sie Ordnerstrukturen für verschiedene Modelltypen beim Herunterladen von Civitai.",
@@ -363,8 +413,10 @@
},
"refresh": {
"title": "Modelliste aktualisieren",
"quick": "Schnelle Aktualisierung (inkrementell)",
"full": "Vollständiger Neuaufbau (komplett)"
"quick": "Änderungen synchronisieren",
"quickTooltip": "Nach neuen oder fehlenden Modelldateien suchen, damit die Liste aktuell bleibt.",
"full": "Cache neu aufbauen",
"fullTooltip": "Alle Modelldetails aus Metadatendateien neu laden nutzen, wenn die Bibliothek veraltet wirkt oder nach manuellen Änderungen."
},
"fetch": {
"title": "Metadaten von Civitai abrufen",
@@ -385,6 +437,13 @@
"favorites": {
"title": "Nur Favoriten anzeigen",
"action": "Favoriten"
},
"updates": {
"title": "Nur Modelle mit verfügbaren Updates anzeigen",
"action": "Updates",
"menuLabel": "Weitere Update-Optionen anzeigen",
"check": "Updates prüfen",
"checkTooltip": "Die Aktualisierungssuche kann einige Zeit dauern."
}
},
"bulkOperations": {
@@ -396,6 +455,7 @@
"setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"checkUpdates": "Auswahl auf Updates prüfen",
"moveAll": "Alle in Ordner verschieben",
"autoOrganize": "Automatisch organisieren",
"deleteAll": "Alle Modelle löschen",
@@ -529,13 +589,19 @@
"title": "Embedding-Modelle"
},
"sidebar": {
"modelRoot": "Modell-Stammverzeichnis",
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
"pinSidebar": "Sidebar anheften",
"unpinSidebar": "Sidebar lösen",
"switchToListView": "Zur Listenansicht wechseln",
"switchToTreeView": "Zur Baumansicht wechseln",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar"
"recursiveOn": "Unterordner durchsuchen",
"recursiveOff": "Nur aktuellen Ordner durchsuchen",
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden."
}
},
"statistics": {
"title": "Statistiken",
@@ -610,6 +676,14 @@
"downloadedPreview": "Vorschaubild heruntergeladen",
"downloadingFile": "{type}-Datei wird heruntergeladen",
"finalizing": "Download wird abgeschlossen..."
},
"progress": {
"currentFile": "Aktuelle Datei:",
"downloading": "Wird heruntergeladen: {name}",
"transferred": "Heruntergeladen: {downloaded} / {total}",
"transferredSimple": "Heruntergeladen: {downloaded}",
"transferredUnknown": "Heruntergeladen: --",
"speed": "Geschwindigkeit: {speed}"
}
},
"move": {
@@ -657,6 +731,12 @@
"countMessage": "Modelle werden dauerhaft gelöscht.",
"action": "Alle löschen"
},
"checkUpdates": {
"title": "Alle {typePlural} auf Updates prüfen?",
"message": "Damit werden alle {typePlural} in deiner Bibliothek auf Updates geprüft. Bei großen Sammlungen kann das etwas länger dauern.",
"tip": "Du möchtest in Etappen prüfen? Wechsle in den Sammelmodus, wähle die benötigten Modelle aus und nutze anschließend \"Auswahl auf Updates prüfen\".",
"action": "Alles prüfen"
},
"bulkAddTags": {
"title": "Tags zu mehreren Modellen hinzufügen",
"description": "Tags hinzufügen zu",
@@ -793,13 +873,55 @@
"tabs": {
"examples": "Beispiele",
"description": "Modellbeschreibung",
"recipes": "Rezepte"
"recipes": "Rezepte",
"versions": "Versionen"
},
"loading": {
"exampleImages": "Beispielbilder werden geladen...",
"description": "Modellbeschreibung wird geladen...",
"recipes": "Rezepte werden geladen...",
"examples": "Beispiele werden geladen..."
"examples": "Beispiele werden geladen...",
"versions": "Versionen werden geladen..."
},
"versions": {
"heading": "Modellversionen",
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
"media": {
"placeholder": "Keine Vorschau"
},
"labels": {
"unnamed": "Unbenannte Version",
"noDetails": "Keine zusätzlichen Details"
},
"badges": {
"current": "Aktuelle Version",
"inLibrary": "In der Bibliothek",
"newer": "Neuere Version",
"ignored": "Ignoriert"
},
"actions": {
"download": "Herunterladen",
"delete": "Löschen",
"ignore": "Ignorieren",
"unignore": "Ignorierung aufheben",
"resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen",
"ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren",
"viewLocalVersions": "Alle lokalen Versionen anzeigen",
"viewLocalTooltip": "Demnächst verfügbar"
},
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
"error": "Versionen konnten nicht geladen werden.",
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",
"confirm": {
"delete": "Diese Version aus Ihrer Bibliothek löschen?"
},
"toast": {
"modelIgnored": "Aktualisierungen für dieses Modell werden ignoriert",
"modelResumed": "Aktualisierungen für dieses Modell werden wieder geprüft",
"versionIgnored": "Aktualisierungen für diese Version werden ignoriert",
"versionUnignored": "Version wurde wieder aktiviert",
"versionDeleted": "Version gelöscht"
}
}
}
},
@@ -906,7 +1028,9 @@
"loraFailedToSend": "Fehler beim Senden der LoRA an den Workflow",
"recipeAdded": "Rezept zum Workflow hinzugefügt",
"recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow"
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt"
},
"nodeSelector": {
"recipe": "Rezept",
@@ -951,6 +1075,11 @@
},
"update": {
"title": "Nach Updates suchen",
"notificationsTitle": "Benachrichtigungszentrum",
"tabs": {
"updates": "Aktualisierungen",
"messages": "Mitteilungen"
},
"updateAvailable": "Update verfügbar",
"noChangelogAvailable": "Kein detailliertes Changelog verfügbar. Weitere Informationen auf GitHub.",
"currentVersion": "Aktuelle Version",
@@ -982,6 +1111,13 @@
"nightly": {
"warning": "Warnung: Nightly Builds können experimentelle Funktionen enthalten und könnten instabil sein.",
"enable": "Nightly Updates aktivieren"
},
"banners": {
"recent": "Neueste Mitteilungen",
"empty": "Keine aktuellen Banner verfügbar.",
"shown": "{time} angezeigt",
"dismissed": "{time} geschlossen",
"active": "Aktiv"
}
},
"support": {
@@ -1101,6 +1237,12 @@
"bulkContentRatingSet": "Inhaltsbewertung auf {level} für {count} Modell(e) gesetzt",
"bulkContentRatingPartial": "Inhaltsbewertung auf {level} für {success} Modell(e) gesetzt, {failed} fehlgeschlagen",
"bulkContentRatingFailed": "Inhaltsbewertung für ausgewählte Modelle konnte nicht aktualisiert werden",
"bulkUpdatesChecking": "Ausgewählte {type}-Modelle werden auf Updates geprüft...",
"bulkUpdatesSuccess": "Updates für {count} ausgewählte {type}-Modelle verfügbar",
"bulkUpdatesNone": "Keine Updates für ausgewählte {type}-Modelle gefunden",
"bulkUpdatesMissing": "Ausgewählte {type}-Modelle sind nicht mit Civitai-Updates verknüpft",
"bulkUpdatesPartialMissing": "{missing} ausgewählte {type}-Modelle ohne Civitai-Verknüpfung übersprungen",
"bulkUpdatesFailed": "Updates für ausgewählte {type}-Modelle konnten nicht geprüft werden: {message}",
"invalidCharactersRemoved": "Ungültige Zeichen aus Dateiname entfernt",
"filenameCannotBeEmpty": "Dateiname darf nicht leer sein",
"renameFailed": "Fehler beim Umbenennen der Datei: {message}",
@@ -1210,6 +1352,8 @@
"pauseFailed": "Fehler beim Pausieren des Downloads: {error}",
"downloadResumed": "Download fortgesetzt",
"resumeFailed": "Fehler beim Fortsetzen des Downloads: {error}",
"downloadStopped": "Download abgebrochen",
"stopFailed": "Download konnte nicht abgebrochen werden: {error}",
"deleted": "Beispielbild gelöscht",
"deleteFailed": "Fehler beim Löschen des Beispielbilds",
"setPreviewFailed": "Fehler beim Setzen des Vorschaubilds"

View File

@@ -101,7 +101,12 @@
"checkpointNameCopied": "Checkpoint name copied",
"toggleBlur": "Toggle blur",
"show": "Show",
"openExampleImages": "Open Example Images Folder"
"openExampleImages": "Open Example Images Folder",
"replacePreview": "Replace Preview",
"copyCheckpointName": "Copy checkpoint name",
"copyEmbeddingName": "Copy embedding name",
"sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI"
},
"nsfw": {
"matureContent": "Mature Content",
@@ -115,12 +120,17 @@
"updateFailed": "Failed to update favorite status"
},
"sendToWorkflow": {
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented"
"checkpointNotImplemented": "Send checkpoint to workflow - feature to be implemented",
"missingPath": "Unable to determine model path for this card"
},
"exampleImages": {
"checkError": "Error checking for example images",
"missingHash": "Missing model hash information.",
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
},
"badges": {
"update": "Update",
"updateAvailable": "Update available"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "Set a download location before downloading example images.",
"unavailable": "Example image downloads aren't available yet. Try again after the page finishes loading."
},
"checkModelUpdates": {
"label": "Check for updates",
"loading": "Checking for {type} updates...",
"success": "Found {count} update(s) for {type}s",
"none": "All {type}s are up to date",
"error": "Failed to check for {type} updates: {message}"
},
"cleanupExampleImages": {
"label": "Clean up example image folders",
"success": "Moved {count} folder(s) to the deleted folder",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "Check Updates",
"notifications": "Notifications",
"support": "Support"
}
},
@@ -199,6 +217,7 @@
"videoSettings": "Video Settings",
"layoutSettings": "Layout Settings",
"folderSettings": "Folder Settings",
"priorityTags": "Priority Tags",
"downloadPathTemplates": "Download Path Templates",
"exampleImages": "Example Images",
"misc": "Misc.",
@@ -219,26 +238,36 @@
"displayDensity": "Display Density",
"displayDensityOptions": {
"default": "Default",
"medium": "Medium",
"medium": "Medium",
"compact": "Compact"
},
"displayDensityHelp": "Choose how many cards to display per row:",
"displayDensityDetails": {
"default": "Default: 5 (1080p), 6 (2K), 8 (4K)",
"medium": "Medium: 6 (1080p), 7 (2K), 9 (4K)",
"compact": "Compact: 7 (1080p), 8 (2K), 10 (4K)"
"default": "5 (1080p), 6 (2K), 8 (4K)",
"medium": "6 (1080p), 7 (2K), 9 (4K)",
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
"showFolderSidebar": "Show Folder Sidebar",
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
"cardInfoDisplay": "Card Info Display",
"cardInfoDisplayOptions": {
"always": "Always Visible",
"hover": "Reveal on Hover"
},
"cardInfoDisplayHelp": "Choose when to display model information and action buttons:",
"cardInfoDisplayDetails": {
"always": "Always Visible: Headers and footers are always visible",
"hover": "Reveal on Hover: Headers and footers only appear when hovering over a card"
}
"cardInfoDisplayHelp": "Choose when to display model information and action buttons",
"modelCardFooterAction": "Model Card Button Action",
"modelCardFooterActionOptions": {
"exampleImages": "Open Example Images",
"replacePreview": "Replace Preview"
},
"modelCardFooterActionHelp": "Choose what the bottom-right card button does",
"modelNameDisplay": "Model Name Display",
"modelNameDisplayOptions": {
"modelName": "Model Name",
"fileName": "File Name"
},
"modelNameDisplayHelp": "Choose what to display in the model card footer"
},
"folderSettings": {
"activeLibrary": "Active Library",
@@ -253,6 +282,26 @@
"defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves",
"noDefault": "No Default"
},
"priorityTags": {
"title": "Priority Tags",
"description": "Customize the tag priority order for each model type (e.g., character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "Open priority tags help",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "Priority tags updated.",
"saveError": "Failed to update priority tags.",
"loadingSuggestions": "Loading suggestions...",
"validation": {
"missingClosingParen": "Entry {index} is missing a closing parenthesis.",
"missingCanonical": "Entry {index} must include a canonical tag name.",
"duplicateCanonical": "The canonical tag \"{tag}\" appears more than once.",
"unknown": "Invalid priority tag configuration."
}
},
"downloadPathTemplates": {
"title": "Download Path Templates",
"help": "Configure folder structures for different model types when downloading from Civitai.",
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "Refresh model list",
"quick": "Quick Refresh (incremental)",
"full": "Full Rebuild (complete)"
"quick": "Sync Changes",
"quickTooltip": "Scan for new or missing model files so the list stays current.",
"full": "Rebuild Cache",
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
},
"fetch": {
"title": "Fetch metadata from Civitai",
@@ -385,20 +436,28 @@
"favorites": {
"title": "Show Favorites Only",
"action": "Favorites"
},
"updates": {
"title": "Show models with updates available",
"action": "Updates",
"menuLabel": "Show update options",
"check": "Check updates",
"checkTooltip": "Checking updates may take a while."
}
},
"bulkOperations": {
"selected": "{count} selected",
"selectedSuffix": "selected",
"viewSelected": "View Selected",
"addTags": "Add Tags to All",
"setBaseModel": "Set Base Model for All",
"setContentRating": "Set Content Rating for All",
"copyAll": "Copy All Syntax",
"refreshAll": "Refresh All Metadata",
"moveAll": "Move All to Folder",
"addTags": "Add Tags to Selected",
"setBaseModel": "Set Base Model for Selected",
"setContentRating": "Set Content Rating for Selected",
"copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata",
"checkUpdates": "Check Updates for Selected",
"moveAll": "Move Selected to Folder",
"autoOrganize": "Auto-Organize Selected",
"deleteAll": "Delete All Models",
"deleteAll": "Delete Selected Models",
"clear": "Clear Selection",
"autoOrganizeProgress": {
"initializing": "Initializing auto-organize...",
@@ -529,13 +588,19 @@
"title": "Embedding Models"
},
"sidebar": {
"modelRoot": "Model Root",
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
"pinSidebar": "Pin Sidebar",
"unpinSidebar": "Unpin Sidebar",
"switchToListView": "Switch to List View",
"switchToTreeView": "Switch to Tree View",
"collapseAllDisabled": "Not available in list view"
"recursiveOn": "Search subfolders",
"recursiveOff": "Search current folder only",
"recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view",
"dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move."
}
},
"statistics": {
"title": "Statistics",
@@ -610,6 +675,14 @@
"downloadedPreview": "Downloaded preview image",
"downloadingFile": "Downloading {type} file",
"finalizing": "Finalizing download..."
},
"progress": {
"currentFile": "Current file:",
"downloading": "Downloading: {name}",
"transferred": "Transferred: {downloaded} / {total}",
"transferredSimple": "Transferred: {downloaded}",
"transferredUnknown": "Transferred: --",
"speed": "Speed: {speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "models will be permanently deleted.",
"action": "Delete All"
},
"checkUpdates": {
"title": "Check updates for all {typePlural}?",
"message": "This checks every {typePlural} in your library for updates. Large collections may take a little longer.",
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
"action": "Check All"
},
"bulkAddTags": {
"title": "Add Tags to Multiple Models",
"description": "Add tags to",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "Examples",
"description": "Model Description",
"recipes": "Recipes"
"recipes": "Recipes",
"versions": "Versions"
},
"loading": {
"exampleImages": "Loading example images...",
"description": "Loading model description...",
"recipes": "Loading recipes...",
"examples": "Loading examples..."
"examples": "Loading examples...",
"versions": "Loading versions..."
},
"versions": {
"heading": "Model versions",
"copy": "Track and manage every version of this model in one place.",
"media": {
"placeholder": "No preview"
},
"labels": {
"unnamed": "Untitled Version",
"noDetails": "No additional details"
},
"badges": {
"current": "Current Version",
"inLibrary": "In Library",
"newer": "Newer Version",
"ignored": "Ignored"
},
"actions": {
"download": "Download",
"delete": "Delete",
"ignore": "Ignore",
"unignore": "Unignore",
"resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model",
"viewLocalVersions": "View all local versions",
"viewLocalTooltip": "Coming soon"
},
"empty": "No version history available for this model yet.",
"error": "Failed to load versions.",
"missingModelId": "This model is missing a Civitai model id.",
"confirm": {
"delete": "Delete this version from your library?"
},
"toast": {
"modelIgnored": "Updates ignored for this model",
"modelResumed": "Update tracking resumed",
"versionIgnored": "Updates ignored for this version",
"versionUnignored": "Version re-enabled",
"versionDeleted": "Version deleted"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "Failed to send LoRA to workflow",
"recipeAdded": "Recipe appended to workflow",
"recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow"
"recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noTargetNodeSelected": "No target node selected"
},
"nodeSelector": {
"recipe": "Recipe",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "Check for Updates",
"notificationsTitle": "Notifications",
"tabs": {
"updates": "Updates",
"messages": "Messages"
},
"updateAvailable": "Update Available",
"noChangelogAvailable": "No detailed changelog available. Check GitHub for more information.",
"currentVersion": "Current Version",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "Warning: Nightly builds may contain experimental features and could be unstable.",
"enable": "Enable Nightly Updates"
},
"banners": {
"recent": "Recent messages",
"empty": "No recent banners yet.",
"shown": "Shown {time}",
"dismissed": "Dismissed {time}",
"active": "Active"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "Set content rating to {level} for {count} model(s)",
"bulkContentRatingPartial": "Set content rating to {level} for {success} model(s), {failed} failed",
"bulkContentRatingFailed": "Failed to update content rating for selected models",
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
"bulkUpdatesNone": "No updates found for selected {type}(s)",
"bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates",
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links",
"bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}",
"invalidCharactersRemoved": "Invalid characters removed from filename",
"filenameCannotBeEmpty": "File name cannot be empty",
"renameFailed": "Failed to rename file: {message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "Failed to pause download: {error}",
"downloadResumed": "Download resumed",
"resumeFailed": "Failed to resume download: {error}",
"downloadStopped": "Download cancelled",
"stopFailed": "Failed to cancel download: {error}",
"deleted": "Example image deleted",
"deleteFailed": "Failed to delete example image",
"setPreviewFailed": "Failed to set preview image"

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Bytes",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "Nombre del checkpoint copiado",
"toggleBlur": "Alternar difuminado",
"show": "Mostrar",
"openExampleImages": "Abrir carpeta de imágenes de ejemplo"
"openExampleImages": "Abrir carpeta de imágenes de ejemplo",
"replacePreview": "Reemplazar vista previa",
"copyCheckpointName": "Copiar nombre del checkpoint",
"copyEmbeddingName": "Copiar nombre del embedding",
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
},
"nsfw": {
"matureContent": "Contenido para adultos",
@@ -115,12 +120,17 @@
"updateFailed": "Error al actualizar estado de favoritos"
},
"sendToWorkflow": {
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar"
"checkpointNotImplemented": "Enviar checkpoint al flujo de trabajo - función por implementar",
"missingPath": "No se puede determinar la ruta del modelo para esta tarjeta"
},
"exampleImages": {
"checkError": "Error al verificar imágenes de ejemplo",
"missingHash": "Falta información del hash del modelo.",
"noRemoteImagesAvailable": "No hay imágenes de ejemplo remotas disponibles para este modelo en Civitai"
},
"badges": {
"update": "Actualización",
"updateAvailable": "Actualización disponible"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "Establece una ubicación de descarga antes de descargar imágenes de ejemplo.",
"unavailable": "Las descargas de imágenes de ejemplo aún no están disponibles. Intenta de nuevo después de que la página termine de cargar."
},
"checkModelUpdates": {
"label": "Buscar actualizaciones",
"loading": "Buscando actualizaciones de {type}...",
"success": "Se encontraron {count} actualización(es) para {type}",
"none": "Todos los {type} están actualizados",
"error": "Error al buscar actualizaciones de {type}: {message}"
},
"cleanupExampleImages": {
"label": "Limpiar carpetas de imágenes de ejemplo",
"success": "Se movieron {count} carpeta(s) a la carpeta de eliminados",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "Comprobar actualizaciones",
"notifications": "Notificaciones",
"support": "Soporte"
}
},
@@ -199,6 +217,7 @@
"videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño",
"folderSettings": "Configuración de carpetas",
"priorityTags": "Etiquetas prioritarias",
"downloadPathTemplates": "Plantillas de rutas de descarga",
"exampleImages": "Imágenes de ejemplo",
"misc": "Varios",
@@ -224,21 +243,31 @@
},
"displayDensityHelp": "Elige cuántas tarjetas mostrar por fila:",
"displayDensityDetails": {
"default": "Predeterminado: 5 (1080p), 6 (2K), 8 (4K)",
"medium": "Medio: 6 (1080p), 7 (2K), 9 (4K)",
"compact": "Compacto: 7 (1080p), 8 (2K), 10 (4K)"
"default": "5 (1080p), 6 (2K), 8 (4K)",
"medium": "6 (1080p), 7 (2K), 9 (4K)",
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
"showFolderSidebar": "Mostrar barra lateral de carpetas",
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
"cardInfoDisplay": "Visualización de información de tarjeta",
"cardInfoDisplayOptions": {
"always": "Siempre visible",
"hover": "Mostrar al pasar el ratón"
},
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción:",
"cardInfoDisplayDetails": {
"always": "Siempre visible: Los encabezados y pies de página siempre son visibles",
"hover": "Mostrar al pasar el ratón: Los encabezados y pies de página solo aparecen al pasar el ratón sobre una tarjeta"
}
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
"modelCardFooterAction": "Acción del botón de tarjeta de modelo",
"modelCardFooterActionOptions": {
"exampleImages": "Abrir imágenes de ejemplo",
"replacePreview": "Reemplazar vista previa"
},
"modelCardFooterActionHelp": "Elige qué hace el botón en la esquina inferior derecha de la tarjeta",
"modelNameDisplay": "Visualización del nombre del modelo",
"modelNameDisplayOptions": {
"modelName": "Nombre del modelo",
"fileName": "Nombre del archivo"
},
"modelNameDisplayHelp": "Elige qué mostrar en el pie de la tarjeta del modelo"
},
"folderSettings": {
"activeLibrary": "Biblioteca activa",
@@ -253,6 +282,26 @@
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"noDefault": "Sin predeterminado"
},
"priorityTags": {
"title": "Etiquetas prioritarias",
"description": "Personaliza el orden de prioridad de etiquetas para cada tipo de modelo (p. ej., character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "Abrir ayuda de etiquetas prioritarias",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "Etiquetas prioritarias actualizadas.",
"saveError": "Error al actualizar las etiquetas prioritarias.",
"loadingSuggestions": "Cargando sugerencias...",
"validation": {
"missingClosingParen": "A la entrada {index} le falta un paréntesis de cierre.",
"missingCanonical": "La entrada {index} debe incluir un nombre de etiqueta canónica.",
"duplicateCanonical": "La etiqueta canónica \"{tag}\" aparece más de una vez.",
"unknown": "Configuración de etiquetas prioritarias no válida."
}
},
"downloadPathTemplates": {
"title": "Plantillas de rutas de descarga",
"help": "Configurar estructuras de carpetas para diferentes tipos de modelos al descargar de Civitai.",
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "Actualizar lista de modelos",
"quick": "Actualización rápida (incremental)",
"full": "Reconstrucción completa"
"quick": "Sincronizar cambios",
"quickTooltip": "Busca archivos de modelo nuevos o faltantes para mantener la lista al día.",
"full": "Reconstruir caché",
"fullTooltip": "Vuelve a cargar todos los detalles desde los archivos de metadatos; úsalo si la biblioteca parece desactualizada o tras ediciones manuales."
},
"fetch": {
"title": "Obtener metadatos de Civitai",
@@ -385,6 +436,13 @@
"favorites": {
"title": "Mostrar solo favoritos",
"action": "Favoritos"
},
"updates": {
"title": "Mostrar solo modelos con actualizaciones disponibles",
"action": "Actualizaciones",
"menuLabel": "Mostrar opciones de actualización",
"check": "Buscar actualizaciones",
"checkTooltip": "Comprobar actualizaciones puede tardar."
}
},
"bulkOperations": {
@@ -396,6 +454,7 @@
"setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"checkUpdates": "Comprobar actualizaciones para la selección",
"moveAll": "Mover todos a carpeta",
"autoOrganize": "Auto-organizar seleccionados",
"deleteAll": "Eliminar todos los modelos",
@@ -529,13 +588,19 @@
"title": "Modelos embedding"
},
"sidebar": {
"modelRoot": "Raíz del modelo",
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
"pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral",
"switchToListView": "Cambiar a vista de lista",
"switchToTreeView": "Cambiar a vista de árbol",
"collapseAllDisabled": "No disponible en vista de lista"
"recursiveOn": "Buscar en subcarpetas",
"recursiveOff": "Buscar solo en la carpeta actual",
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento."
}
},
"statistics": {
"title": "Estadísticas",
@@ -610,6 +675,14 @@
"downloadedPreview": "Imagen de vista previa descargada",
"downloadingFile": "Descargando archivo de {type}",
"finalizing": "Finalizando descarga..."
},
"progress": {
"currentFile": "Archivo actual:",
"downloading": "Descargando: {name}",
"transferred": "Descargado: {downloaded} / {total}",
"transferredSimple": "Descargado: {downloaded}",
"transferredUnknown": "Descargado: --",
"speed": "Velocidad: {speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "modelos serán eliminados permanentemente.",
"action": "Eliminar todo"
},
"checkUpdates": {
"title": "¿Comprobar actualizaciones para todos los {typePlural}?",
"message": "Esto comprobará las actualizaciones de todos los {typePlural} de tu biblioteca. En colecciones grandes puede tardar un poco más.",
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
"action": "Comprobar todo"
},
"bulkAddTags": {
"title": "Añadir etiquetas a múltiples modelos",
"description": "Añadir etiquetas a",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "Ejemplos",
"description": "Descripción del modelo",
"recipes": "Recetas"
"recipes": "Recetas",
"versions": "Versiones"
},
"loading": {
"exampleImages": "Cargando imágenes de ejemplo...",
"description": "Cargando descripción del modelo...",
"recipes": "Cargando recetas...",
"examples": "Cargando ejemplos..."
"examples": "Cargando ejemplos...",
"versions": "Cargando versiones..."
},
"versions": {
"heading": "Versiones del modelo",
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
"media": {
"placeholder": "Sin vista previa"
},
"labels": {
"unnamed": "Versión sin nombre",
"noDetails": "Sin detalles adicionales"
},
"badges": {
"current": "Versión actual",
"inLibrary": "En la biblioteca",
"newer": "Versión más reciente",
"ignored": "Ignorada"
},
"actions": {
"download": "Descargar",
"delete": "Eliminar",
"ignore": "Ignorar",
"unignore": "Dejar de ignorar",
"resumeModelUpdates": "Reanudar actualizaciones para este modelo",
"ignoreModelUpdates": "Ignorar actualizaciones para este modelo",
"viewLocalVersions": "Ver todas las versiones locales",
"viewLocalTooltip": "Disponible pronto"
},
"empty": "Aún no hay historial de versiones para este modelo.",
"error": "No se pudieron cargar las versiones.",
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",
"confirm": {
"delete": "¿Eliminar esta versión de tu biblioteca?"
},
"toast": {
"modelIgnored": "Se ignoran las actualizaciones de este modelo",
"modelResumed": "Seguimiento de actualizaciones reanudado",
"versionIgnored": "Se ignoran las actualizaciones de esta versión",
"versionUnignored": "Versión habilitada nuevamente",
"versionDeleted": "Versión eliminada"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "Error al enviar LoRA al flujo de trabajo",
"recipeAdded": "Receta añadida al flujo de trabajo",
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo"
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino"
},
"nodeSelector": {
"recipe": "Receta",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "Comprobar actualizaciones",
"notificationsTitle": "Centro de notificaciones",
"tabs": {
"updates": "Actualizaciones",
"messages": "Mensajes"
},
"updateAvailable": "Actualización disponible",
"noChangelogAvailable": "No hay registro de cambios detallado disponible. Revisa GitHub para más información.",
"currentVersion": "Versión actual",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "Advertencia: Las compilaciones nocturnas pueden contener características experimentales y podrían ser inestables.",
"enable": "Habilitar actualizaciones nocturnas"
},
"banners": {
"recent": "Notificaciones recientes",
"empty": "No hay banners recientes.",
"shown": "Mostrado {time}",
"dismissed": "Descartado {time}",
"active": "Activo"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "Clasificación de contenido establecida en {level} para {count} modelo(s)",
"bulkContentRatingPartial": "Clasificación de contenido establecida en {level} para {success} modelo(s), {failed} fallaron",
"bulkContentRatingFailed": "No se pudo actualizar la clasificación de contenido para los modelos seleccionados",
"bulkUpdatesChecking": "Comprobando actualizaciones para {type} seleccionados...",
"bulkUpdatesSuccess": "Actualizaciones disponibles para {count} {type} seleccionados",
"bulkUpdatesNone": "No se encontraron actualizaciones para los {type} seleccionados",
"bulkUpdatesMissing": "Los {type} seleccionados no están vinculados a actualizaciones de Civitai",
"bulkUpdatesPartialMissing": "Se omitieron {missing} {type} seleccionados sin enlace de Civitai",
"bulkUpdatesFailed": "Error al comprobar actualizaciones para los {type} seleccionados: {message}",
"invalidCharactersRemoved": "Caracteres inválidos eliminados del nombre de archivo",
"filenameCannotBeEmpty": "El nombre de archivo no puede estar vacío",
"renameFailed": "Error al renombrar archivo: {message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "Error al pausar descarga: {error}",
"downloadResumed": "Descarga reanudada",
"resumeFailed": "Error al reanudar descarga: {error}",
"downloadStopped": "Descarga cancelada",
"stopFailed": "Error al cancelar descarga: {error}",
"deleted": "Imagen de ejemplo eliminada",
"deleteFailed": "Error al eliminar imagen de ejemplo",
"setPreviewFailed": "Error al establecer imagen de vista previa"

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Octets",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "Nom du checkpoint copié",
"toggleBlur": "Basculer le flou",
"show": "Afficher",
"openExampleImages": "Ouvrir le dossier d'images d'exemple"
"openExampleImages": "Ouvrir le dossier d'images d'exemple",
"replacePreview": "Remplacer l'aperçu",
"copyCheckpointName": "Copier le nom du checkpoint",
"copyEmbeddingName": "Copier le nom de l'embedding",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
},
"nsfw": {
"matureContent": "Contenu pour adultes",
@@ -115,12 +120,17 @@
"updateFailed": "Échec de la mise à jour du statut des favoris"
},
"sendToWorkflow": {
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter"
"checkpointNotImplemented": "Envoyer le checkpoint vers le workflow - fonctionnalité à implémenter",
"missingPath": "Impossible de déterminer le chemin du modèle pour cette carte"
},
"exampleImages": {
"checkError": "Erreur lors de la vérification des images d'exemple",
"missingHash": "Informations de hachage du modèle manquantes.",
"noRemoteImagesAvailable": "Aucune image d'exemple distante disponible pour ce modèle sur Civitai"
},
"badges": {
"update": "Mise à jour",
"updateAvailable": "Mise à jour disponible"
}
},
"globalContextMenu": {
@@ -129,8 +139,15 @@
"missingPath": "Définissez un emplacement de téléchargement avant de télécharger les images d'exemple.",
"unavailable": "Le téléchargement des images d'exemple n'est pas encore disponible. Réessayez après le chargement complet de la page."
},
"checkModelUpdates": {
"label": "Vérifier les mises à jour",
"loading": "Recherche de mises à jour pour {type}...",
"success": "{count} mise(s) à jour trouvée(s) pour {type}",
"none": "Tous les {type} sont à jour",
"error": "Échec de la vérification des mises à jour pour {type} : {message}"
},
"cleanupExampleImages": {
"label": "Nettoyer les dossiers d'images d'exemple",
"label": "Supprimer les dossiers d'exemples orphelins",
"success": "{count} dossier(s) déplacé(s) vers le dossier supprimé",
"none": "Aucun dossier d'images d'exemple à nettoyer",
"partial": "Nettoyage terminé avec {failures} dossier(s) ignoré(s)",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "Vérifier les mises à jour",
"notifications": "Notifications",
"support": "Support"
}
},
@@ -203,7 +221,8 @@
"exampleImages": "Images d'exemple",
"misc": "Divers",
"metadataArchive": "Base de données d'archive des métadonnées",
"proxySettings": "Paramètres du proxy"
"proxySettings": "Paramètres du proxy",
"priorityTags": "Étiquettes prioritaires"
},
"contentFiltering": {
"blurNsfwContent": "Flouter le contenu NSFW",
@@ -224,21 +243,31 @@
},
"displayDensityHelp": "Choisissez combien de cartes afficher par ligne :",
"displayDensityDetails": {
"default": "Par défaut : 5 (1080p), 6 (2K), 8 (4K)",
"medium": "Moyen : 6 (1080p), 7 (2K), 9 (4K)",
"compact": "Compact : 7 (1080p), 8 (2K), 10 (4K)"
"default": "5 (1080p), 6 (2K), 8 (4K)",
"medium": "6 (1080p), 7 (2K), 9 (4K)",
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
"showFolderSidebar": "Afficher la barre latérale des dossiers",
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
"cardInfoDisplay": "Affichage des informations de carte",
"cardInfoDisplayOptions": {
"always": "Toujours visible",
"hover": "Révéler au survol"
},
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action :",
"cardInfoDisplayDetails": {
"always": "Toujours visible : Les en-têtes et pieds de page sont toujours visibles",
"hover": "Révéler au survol : Les en-têtes et pieds de page n'apparaissent qu'au survol d'une carte"
}
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
"modelCardFooterAction": "Action du bouton de carte de modèle",
"modelCardFooterActionOptions": {
"exampleImages": "Ouvrir les images d'exemple",
"replacePreview": "Remplacer l'aperçu"
},
"modelCardFooterActionHelp": "Choisissez ce que fait le bouton en bas à droite de la carte",
"modelNameDisplay": "Affichage du nom du modèle",
"modelNameDisplayOptions": {
"modelName": "Nom du modèle",
"fileName": "Nom du fichier"
},
"modelNameDisplayHelp": "Choisissez ce qui doit être affiché dans le pied de page de la carte du modèle"
},
"folderSettings": {
"activeLibrary": "Bibliothèque active",
@@ -345,6 +374,26 @@
"proxyPassword": "Mot de passe (optionnel)",
"proxyPasswordPlaceholder": "mot_de_passe",
"proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)"
},
"priorityTags": {
"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))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "Étiquettes prioritaires mises à jour.",
"saveError": "Échec de la mise à jour des étiquettes prioritaires.",
"loadingSuggestions": "Chargement des suggestions...",
"validation": {
"missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.",
"missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.",
"duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.",
"unknown": "Configuration d'étiquettes prioritaires invalide."
}
}
},
"loras": {
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "Actualiser la liste des modèles",
"quick": "Actualisation rapide (incrémentale)",
"full": "Reconstruction complète"
"quick": "Synchroniser les changements",
"quickTooltip": "Analyse les nouveaux fichiers de modèle ou les fichiers manquants pour garder la liste à jour.",
"full": "Reconstruire le cache",
"fullTooltip": "Recharge tous les détails des modèles depuis les fichiers metadata — à utiliser si la bibliothèque paraît obsolète ou après des modifications manuelles."
},
"fetch": {
"title": "Récupérer les métadonnées depuis Civitai",
@@ -385,6 +436,13 @@
"favorites": {
"title": "Afficher uniquement les favoris",
"action": "Favoris"
},
"updates": {
"title": "Afficher uniquement les modèles avec des mises à jour disponibles",
"action": "Mises à jour",
"menuLabel": "Afficher les options de mise à jour",
"check": "Rechercher des mises à jour",
"checkTooltip": "La vérification peut prendre du temps."
}
},
"bulkOperations": {
@@ -396,6 +454,7 @@
"setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe",
"refreshAll": "Actualiser toutes les métadonnées",
"checkUpdates": "Vérifier les mises à jour pour la sélection",
"moveAll": "Déplacer tout vers un dossier",
"autoOrganize": "Auto-organiser la sélection",
"deleteAll": "Supprimer tous les modèles",
@@ -529,13 +588,19 @@
"title": "Modèles Embedding"
},
"sidebar": {
"modelRoot": "Racine du modèle",
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
"pinSidebar": "Épingler la barre latérale",
"unpinSidebar": "Désépingler la barre latérale",
"switchToListView": "Passer en vue liste",
"switchToTreeView": "Passer en vue arborescence",
"collapseAllDisabled": "Non disponible en vue liste"
"recursiveOn": "Rechercher dans les sous-dossiers",
"recursiveOff": "Rechercher uniquement dans le dossier actuel",
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement."
}
},
"statistics": {
"title": "Statistiques",
@@ -610,6 +675,14 @@
"downloadedPreview": "Image d'aperçu téléchargée",
"downloadingFile": "Téléchargement du fichier {type}",
"finalizing": "Finalisation du téléchargement..."
},
"progress": {
"currentFile": "Fichier actuel :",
"downloading": "Téléchargement : {name}",
"transferred": "Téléchargé : {downloaded} / {total}",
"transferredSimple": "Téléchargé : {downloaded}",
"transferredUnknown": "Téléchargé : --",
"speed": "Vitesse : {speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "modèles seront définitivement supprimés.",
"action": "Tout supprimer"
},
"checkUpdates": {
"title": "Vérifier les mises à jour pour tous les {typePlural} ?",
"message": "Cette action vérifie les mises à jour pour tous les {typePlural} de votre bibliothèque. Les grandes collections peuvent prendre un peu plus de temps.",
"tip": "Besoin de procéder par étapes ? Passez en mode lot, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
"action": "Tout vérifier"
},
"bulkAddTags": {
"title": "Ajouter des tags à plusieurs modèles",
"description": "Ajouter des tags à",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "Exemples",
"description": "Description du modèle",
"recipes": "Recipes"
"recipes": "Recipes",
"versions": "Versions"
},
"loading": {
"exampleImages": "Chargement des images d'exemple...",
"description": "Chargement de la description du modèle...",
"recipes": "Chargement des recipes...",
"examples": "Chargement des exemples..."
"examples": "Chargement des exemples...",
"versions": "Chargement des versions..."
},
"versions": {
"heading": "Versions du modèle",
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
"media": {
"placeholder": "Aucune prévisualisation"
},
"labels": {
"unnamed": "Version sans nom",
"noDetails": "Aucun détail supplémentaire"
},
"badges": {
"current": "Version actuelle",
"inLibrary": "Dans la bibliothèque",
"newer": "Version plus récente",
"ignored": "Ignorée"
},
"actions": {
"download": "Télécharger",
"delete": "Supprimer",
"ignore": "Ignorer",
"unignore": "Ne plus ignorer",
"resumeModelUpdates": "Reprendre les mises à jour pour ce modèle",
"ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle",
"viewLocalVersions": "Voir toutes les versions locales",
"viewLocalTooltip": "Bientôt disponible"
},
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
"error": "Échec du chargement des versions.",
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",
"confirm": {
"delete": "Supprimer cette version de votre bibliothèque ?"
},
"toast": {
"modelIgnored": "Les mises à jour de ce modèle sont ignorées",
"modelResumed": "Suivi des mises à jour repris",
"versionIgnored": "Les mises à jour de cette version sont ignorées",
"versionUnignored": "Version réactivée",
"versionDeleted": "Version supprimée"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "Échec de l'envoi du LoRA au workflow",
"recipeAdded": "Recipe ajoutée au workflow",
"recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow"
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noTargetNodeSelected": "Aucun nœud cible sélectionné"
},
"nodeSelector": {
"recipe": "Recipe",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "Vérifier les mises à jour",
"notificationsTitle": "Notifications",
"tabs": {
"updates": "Mises à jour",
"messages": "Messages"
},
"updateAvailable": "Mise à jour disponible",
"noChangelogAvailable": "Aucun journal des modifications détaillé disponible. Consultez GitHub pour plus d'informations.",
"currentVersion": "Version actuelle",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "Attention : Les versions nightly peuvent contenir des fonctionnalités expérimentales et être instables.",
"enable": "Activer les mises à jour nightly"
},
"banners": {
"recent": "Messages récents",
"empty": "Aucune bannière récente.",
"shown": "Affiché {time}",
"dismissed": "Ignoré {time}",
"active": "Actif"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "Classification du contenu définie sur {level} pour {count} modèle(s)",
"bulkContentRatingPartial": "Classification du contenu définie sur {level} pour {success} modèle(s), {failed} échec(s)",
"bulkContentRatingFailed": "Impossible de mettre à jour la classification du contenu pour les modèles sélectionnés",
"bulkUpdatesChecking": "Vérification des mises à jour pour les {type} sélectionnés...",
"bulkUpdatesSuccess": "Mises à jour disponibles pour {count} {type} sélectionnés",
"bulkUpdatesNone": "Aucune mise à jour trouvée pour les {type} sélectionnés",
"bulkUpdatesMissing": "Les {type} sélectionnés ne sont pas liés aux mises à jour Civitai",
"bulkUpdatesPartialMissing": "{missing} {type} sélectionnés sans lien Civitai ignorés",
"bulkUpdatesFailed": "Échec de la vérification des mises à jour pour les {type} sélectionnés : {message}",
"invalidCharactersRemoved": "Caractères invalides supprimés du nom de fichier",
"filenameCannotBeEmpty": "Le nom de fichier ne peut pas être vide",
"renameFailed": "Échec du renommage du fichier : {message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "Échec de la mise en pause du téléchargement : {error}",
"downloadResumed": "Téléchargement repris",
"resumeFailed": "Échec de la reprise du téléchargement : {error}",
"downloadStopped": "Téléchargement annulé",
"stopFailed": "Échec de l'annulation du téléchargement : {error}",
"deleted": "Image d'exemple supprimée",
"deleteFailed": "Échec de la suppression de l'image d'exemple",
"setPreviewFailed": "Échec de la définition de l'image d'aperçu"

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 בתים",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "שם Checkpoint הועתק",
"toggleBlur": "הפעל/כבה טשטוש",
"show": "הצג",
"openExampleImages": "פתח תיקיית תמונות דוגמה"
"openExampleImages": "פתח תיקיית תמונות דוגמה",
"replacePreview": "החלף תצוגה מקדימה",
"copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
},
"nsfw": {
"matureContent": "תוכן למבוגרים",
@@ -115,12 +120,17 @@
"updateFailed": "עדכון סטטוס מועדפים נכשל"
},
"sendToWorkflow": {
"checkpointNotImplemented": "שליחת checkpoint ל-workflow - תכונה שתיושם בעתיד"
"checkpointNotImplemented": "שליחת checkpoint ל-workflow - תכונה שתיושם בעתיד",
"missingPath": "לא ניתן לקבוע את נתיב המודל לכרטיס זה"
},
"exampleImages": {
"checkError": "שגיאה בבדיקת תמונות דוגמה",
"missingHash": "חסר מידע hash של המודל.",
"noRemoteImagesAvailable": "אין תמונות דוגמה מרוחקות זמינות למודל זה ב-Civitai"
},
"badges": {
"update": "עדכון",
"updateAvailable": "עדכון זמין"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "הגדר מיקום הורדה לפני הורדת תמונות דוגמה.",
"unavailable": "הורדות תמונות דוגמה אינן זמינות עדיין. נסה שוב לאחר שהדף מסיים להיטען."
},
"checkModelUpdates": {
"label": "בדוק עדכונים",
"loading": "בודק עדכונים עבור {type}...",
"success": "נמצאו {count} עדכונים עבור {type}",
"none": "כל ה-{type} מעודכנים",
"error": "נכשל בבדיקת העדכונים עבור {type}: {message}"
},
"cleanupExampleImages": {
"label": "נקה תיקיות תמונות דוגמה",
"success": "הועברו {count} תיקיות לתיקיית המחוקים",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "בדוק עדכונים",
"notifications": "התראות",
"support": "תמיכה"
}
},
@@ -203,7 +221,8 @@
"exampleImages": "תמונות דוגמה",
"misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"proxySettings": "הגדרות פרוקסי"
"proxySettings": "הגדרות פרוקסי",
"priorityTags": "תגיות עדיפות"
},
"contentFiltering": {
"blurNsfwContent": "טשטש תוכן NSFW",
@@ -224,21 +243,31 @@
},
"displayDensityHelp": "בחר כמה כרטיסים להציג בכל שורה:",
"displayDensityDetails": {
"default": "ברירת מחדל: 5 (1080p), 6 (2K), 8 (4K)",
"medium": "בינוני: 6 (1080p), 7 (2K), 9 (4K)",
"compact": "קומפקטי: 7 (1080p), 8 (2K), 10 (4K)"
"default": "5 (1080p), 6 (2K), 8 (4K)",
"medium": "6 (1080p), 7 (2K), 9 (4K)",
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
"showFolderSidebar": "הצג סרגל צד תיקיות",
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
"cardInfoDisplay": "תצוגת מידע בכרטיס",
"cardInfoDisplayOptions": {
"always": "תמיד גלוי",
"hover": "חשוף בריחוף"
},
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה:",
"cardInfoDisplayDetails": {
"always": "תמיד גלוי: כותרות עליונות ותחתונות תמיד גלויות",
"hover": "חשוף בריחוף: כותרות עליונות ותחתונות מופיעות רק בעת ריחוף מעל כרטיס"
}
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
"modelCardFooterActionOptions": {
"exampleImages": "פתח תמונות דוגמה",
"replacePreview": "החלף תצוגה מקדימה"
},
"modelCardFooterActionHelp": "בחר מה עושה הכפתור בפינה הימנית התחתונה של הכרטיס",
"modelNameDisplay": "תצוגת שם מודל",
"modelNameDisplayOptions": {
"modelName": "שם מודל",
"fileName": "שם קובץ"
},
"modelNameDisplayHelp": "בחר מה להציג בכותרת התחתונה של כרטיס המודל"
},
"folderSettings": {
"activeLibrary": "ספרייה פעילה",
@@ -345,6 +374,26 @@
"proxyPassword": "סיסמה (אופציונלי)",
"proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "תגיות העדיפות עודכנו.",
"saveError": "עדכון תגיות העדיפות נכשל.",
"loadingSuggestions": "טוען הצעות...",
"validation": {
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
"unknown": "תצורת תגיות העדיפות שגויה."
}
}
},
"loras": {
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "רענן רשימת מודלים",
"quick": "רענון מהיר (מצטבר)",
"full": "בנייה מחדש מלאה (שלם)"
"quick": "סנכרון שינויים",
"quickTooltip": "סריקה לאיתור קבצי מודל חדשים או חסרים כדי לשמור את הרשימה מעודכנת.",
"full": "בניית מטמון מחדש",
"fullTooltip": "טוען מחדש את כל פרטי המודלים מקבצי המטא-דאטה לשימוש אם הספרייה נראית לא מעודכנת או לאחר עריכות ידניות."
},
"fetch": {
"title": "אחזר מטא-דאטה מ-Civitai",
@@ -385,6 +436,13 @@
"favorites": {
"title": "הצג מועדפים בלבד",
"action": "מועדפים"
},
"updates": {
"title": "הצג רק דגמים עם עדכונים זמינים",
"action": "עדכונים",
"menuLabel": "הצגת אפשרויות עדכון",
"check": "בדוק עדכונים",
"checkTooltip": "בדיקת עדכונים עלולה לקחת זמן."
}
},
"bulkOperations": {
@@ -396,6 +454,7 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-דאטה",
"checkUpdates": "בדוק עדכונים לבחירה",
"moveAll": "העבר הכל לתיקייה",
"autoOrganize": "ארגן אוטומטית נבחרים",
"deleteAll": "מחק את כל המודלים",
@@ -529,13 +588,19 @@
"title": "מודלי Embedding"
},
"sidebar": {
"modelRoot": "שורש המודלים",
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
"pinSidebar": "נעל סרגל צד",
"unpinSidebar": "שחרר סרגל צד",
"switchToListView": "עבור לתצוגת רשימה",
"switchToTreeView": "עבור לתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה"
"switchToTreeView": "תצוגת עץ",
"recursiveOn": "חיפוש בתיקיות משנה",
"recursiveOff": "חיפוש רק בתיקייה הנוכחית",
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה."
}
},
"statistics": {
"title": "סטטיסטיקה",
@@ -610,6 +675,14 @@
"downloadedPreview": "תמונת תצוגה מקדימה הורדה",
"downloadingFile": "מוריד קובץ {type}",
"finalizing": "מסיים הורדה..."
},
"progress": {
"currentFile": "הקובץ הנוכחי:",
"downloading": "מוריד: {name}",
"transferred": "הורד: {downloaded} / {total}",
"transferredSimple": "הורד: {downloaded}",
"transferredUnknown": "הורד: --",
"speed": "מהירות: {speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "מודלים יימחקו לצמיתות.",
"action": "מחק הכל"
},
"checkUpdates": {
"title": "לבדוק עדכונים לכל ה-{typePlural}?",
"message": "הפעולה תבדוק עדכונים עבור כל ה-{typePlural} בספרייה שלך. באוספים גדולים זה עלול לקחת מעט יותר זמן.",
"tip": "רוצים לחלק למנות קטנות? עברו למצב קבוצתי, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
"action": "בדוק הכל"
},
"bulkAddTags": {
"title": "הוסף תגיות למספר מודלים",
"description": "הוסף תגיות ל-",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "דוגמאות",
"description": "תיאור המודל",
"recipes": "מתכונים"
"recipes": "מתכונים",
"versions": "גרסאות"
},
"loading": {
"exampleImages": "טוען תמונות דוגמה...",
"description": "טוען תיאור מודל...",
"recipes": "טוען מתכונים...",
"examples": "טוען דוגמאות..."
"examples": "טוען דוגמאות...",
"versions": "טוען גרסאות..."
},
"versions": {
"heading": "גרסאות המודל",
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
"media": {
"placeholder": "אין תצוגה מקדימה"
},
"labels": {
"unnamed": "גרסה ללא שם",
"noDetails": "אין פרטים נוספים"
},
"badges": {
"current": "גרסה נוכחית",
"inLibrary": "בספרייה",
"newer": "גרסה חדשה יותר",
"ignored": "התעלם"
},
"actions": {
"download": "הורדה",
"delete": "מחיקה",
"ignore": "התעלם",
"unignore": "בטל התעלמות",
"resumeModelUpdates": "המשך עדכונים עבור מודל זה",
"ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה",
"viewLocalVersions": "הצג את כל הגרסאות המקומיות",
"viewLocalTooltip": "יגיע בקרוב"
},
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
"error": "טעינת הגרסאות נכשלה.",
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",
"confirm": {
"delete": "למחוק גרסה זו מהספרייה שלך?"
},
"toast": {
"modelIgnored": "העדכונים עבור מודל זה נוגבו",
"modelResumed": "מעקב העדכונים חודש",
"versionIgnored": "העדכונים עבור גרסה זו נוגבו",
"versionUnignored": "הגרסה הופעלה מחדש",
"versionDeleted": "הגרסה נמחקה"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "שליחת LoRA ל-workflow נכשלה",
"recipeAdded": "מתכון נוסף ל-workflow",
"recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה"
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noTargetNodeSelected": "לא נבחר צומת יעד"
},
"nodeSelector": {
"recipe": "מתכון",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "בדוק עדכונים",
"notificationsTitle": "מרכז התראות",
"tabs": {
"updates": "עדכונים",
"messages": "הודעות"
},
"updateAvailable": "עדכון זמין",
"noChangelogAvailable": "אין יומן שינויים מפורט זמין. בדוק ב-GitHub למידע נוסף.",
"currentVersion": "גרסה נוכחית",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "אזהרה: גרסאות ליליות עשויות להכיל תכונות ניסיוניות ועלולות להיות לא יציבות.",
"enable": "הפעל עדכונים ליליים"
},
"banners": {
"recent": "הודעות אחרונות",
"empty": "אין כרגע באנרים אחרונים.",
"shown": "הוצג {time}",
"dismissed": "הוסר {time}",
"active": "פעיל"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "דירוג התוכן הוגדר ל-{level} עבור {count} מודלים",
"bulkContentRatingPartial": "דירוג התוכן הוגדר ל-{level} עבור {success} מודלים, {failed} נכשלו",
"bulkContentRatingFailed": "עדכון דירוג התוכן עבור המודלים שנבחרו נכשל",
"bulkUpdatesChecking": "בודק עדכונים עבור {type} שנבחרו...",
"bulkUpdatesSuccess": "יש עדכונים עבור {count} {type} שנבחרו",
"bulkUpdatesNone": "לא נמצאו עדכונים עבור {type} שנבחרו",
"bulkUpdatesMissing": "ה-{type} שנבחרו אינם מקושרים לעדכוני Civitai",
"bulkUpdatesPartialMissing": "דילג על {missing} {type} שנבחרו ללא קישור Civitai",
"bulkUpdatesFailed": "בדיקת העדכונים עבור {type} שנבחרו נכשלה: {message}",
"invalidCharactersRemoved": "תווים לא חוקיים הוסרו משם הקובץ",
"filenameCannotBeEmpty": "שם הקובץ אינו יכול להיות ריק",
"renameFailed": "שינוי שם הקובץ נכשל: {message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "השהיית ההורדה נכשלה: {error}",
"downloadResumed": "ההורדה חודשה",
"resumeFailed": "חידוש ההורדה נכשל: {error}",
"downloadStopped": "ההורדה בוטלה",
"stopFailed": "נכשל בביטול ההורדה: {error}",
"deleted": "תמונת הדוגמה נמחקה",
"deleteFailed": "מחיקת תמונת הדוגמה נכשלה",
"setPreviewFailed": "הגדרת תמונת התצוגה המקדימה נכשלה"

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0バイト",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "checkpointの名前をコピーしました",
"toggleBlur": "ぼかしの切り替え",
"show": "表示",
"openExampleImages": "例画像フォルダを開く"
"openExampleImages": "例画像フォルダを開く",
"replacePreview": "プレビューを置換",
"copyCheckpointName": "checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー",
"sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信"
},
"nsfw": {
"matureContent": "成人向けコンテンツ",
@@ -115,12 +120,17 @@
"updateFailed": "お気に入り状態の更新に失敗しました"
},
"sendToWorkflow": {
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能"
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能",
"missingPath": "このカードのモデルパスを特定できません"
},
"exampleImages": {
"checkError": "例画像の確認中にエラーが発生しました",
"missingHash": "モデルハッシュ情報がありません。",
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
},
"badges": {
"update": "アップデート",
"updateAvailable": "アップデートがあります"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "例画像をダウンロードする前にダウンロード場所を設定してください。",
"unavailable": "例画像のダウンロードはまだ利用できません。ページの読み込みが完了してから再度お試しください。"
},
"checkModelUpdates": {
"label": "アップデートを確認",
"loading": "{type} のアップデートを確認中…",
"success": "{type} のアップデートが {count} 件見つかりました",
"none": "すべての {type} は最新です",
"error": "{type} のアップデート確認に失敗しました: {message}"
},
"cleanupExampleImages": {
"label": "例画像フォルダをクリーンアップ",
"success": "{count} 個のフォルダを削除フォルダに移動しました",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "更新確認",
"notifications": "通知",
"support": "サポート"
}
},
@@ -203,7 +221,8 @@
"exampleImages": "例画像",
"misc": "その他",
"metadataArchive": "メタデータアーカイブデータベース",
"proxySettings": "プロキシ設定"
"proxySettings": "プロキシ設定",
"priorityTags": "優先タグ"
},
"contentFiltering": {
"blurNsfwContent": "NSFWコンテンツをぼかす",
@@ -224,21 +243,31 @@
},
"displayDensityHelp": "1行に表示するカード数を選択",
"displayDensityDetails": {
"default": "デフォルト:51080p、62K、84K",
"medium": "中:61080p、72K、94K",
"compact": "コンパクト:71080p、82K、104K"
"default": "51080p、62K、84K",
"medium": "61080p、72K、94K",
"compact": "71080p、82K、104K"
},
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
"showFolderSidebar": "フォルダサイドバーを表示",
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
"cardInfoDisplay": "カード情報表示",
"cardInfoDisplayOptions": {
"always": "常に表示",
"hover": "ホバー時に表示"
},
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
"cardInfoDisplayDetails": {
"always": "常に表示:ヘッダーとフッターが常に表示されます",
"hover": "ホバー時に表示:カードにホバーしたときのみヘッダーとフッターが表示されます"
}
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
"modelCardFooterAction": "モデルカードボタンのアクション",
"modelCardFooterActionOptions": {
"exampleImages": "例画像を開く",
"replacePreview": "プレビューを置換"
},
"modelCardFooterActionHelp": "カード右下のボタンが何をするかを選択します",
"modelNameDisplay": "モデル名表示",
"modelNameDisplayOptions": {
"modelName": "モデル名",
"fileName": "ファイル名"
},
"modelNameDisplayHelp": "モデルカードのフッターに表示する内容を選択"
},
"folderSettings": {
"activeLibrary": "アクティブライブラリ",
@@ -345,6 +374,26 @@
"proxyPassword": "パスワード(任意)",
"proxyPasswordPlaceholder": "パスワード",
"proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)"
},
"priorityTags": {
"title": "優先タグ",
"description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "優先タグのヘルプを開く",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "チェックポイント",
"embedding": "埋め込み"
},
"saveSuccess": "優先タグを更新しました。",
"saveError": "優先タグの更新に失敗しました。",
"loadingSuggestions": "候補を読み込み中...",
"validation": {
"missingClosingParen": "エントリ {index} に閉じ括弧がありません。",
"missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。",
"duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。",
"unknown": "無効な優先タグ設定です。"
}
}
},
"loras": {
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "モデルリストを更新",
"quick": "クイック更新(増分)",
"full": "完全再構築(完全)"
"quick": "変更を同期",
"quickTooltip": "新しいモデルファイルや欠けているファイルをスキャンして一覧を最新に保ちます。",
"full": "キャッシュを再構築",
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
},
"fetch": {
"title": "Civitaiからメタデータを取得",
@@ -385,6 +436,13 @@
"favorites": {
"title": "お気に入りのみ表示",
"action": "お気に入り"
},
"updates": {
"title": "アップデート可能なモデルのみ表示",
"action": "アップデート",
"menuLabel": "更新オプションを表示",
"check": "アップデートを確認",
"checkTooltip": "確認には時間がかかる場合があります。"
}
},
"bulkOperations": {
@@ -396,6 +454,7 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"checkUpdates": "選択項目の更新を確認",
"moveAll": "すべてをフォルダに移動",
"autoOrganize": "自動整理を実行",
"deleteAll": "すべてのモデルを削除",
@@ -529,13 +588,19 @@
"title": "Embeddingモデル"
},
"sidebar": {
"modelRoot": "モデルルート",
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除",
"switchToListView": "リストビューに切り替え",
"switchToTreeView": "ツリービューに切り替え",
"collapseAllDisabled": "リストビューでは利用できません"
"switchToTreeView": "ツリー表示に切り替え",
"recursiveOn": "サブフォルダーを検索",
"recursiveOff": "現在のフォルダーのみを検索",
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。"
}
},
"statistics": {
"title": "統計",
@@ -610,6 +675,14 @@
"downloadedPreview": "プレビュー画像をダウンロードしました",
"downloadingFile": "{type}ファイルをダウンロード中",
"finalizing": "ダウンロードを完了中..."
},
"progress": {
"currentFile": "現在のファイル:",
"downloading": "ダウンロード中: {name}",
"transferred": "ダウンロード済み: {downloaded} / {total}",
"transferredSimple": "ダウンロード済み: {downloaded}",
"transferredUnknown": "ダウンロード済み: --",
"speed": "速度: {speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "モデルが完全に削除されます。",
"action": "すべて削除"
},
"checkUpdates": {
"title": "すべての{type}の更新を確認しますか?",
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
"tip": "少しずつ確認したい場合はバルクモードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
"action": "すべて確認"
},
"bulkAddTags": {
"title": "複数モデルにタグを追加",
"description": "タグを追加するモデル:",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "例",
"description": "モデル説明",
"recipes": "レシピ"
"recipes": "レシピ",
"versions": "バージョン"
},
"loading": {
"exampleImages": "例画像を読み込み中...",
"description": "モデル説明を読み込み中...",
"recipes": "レシピを読み込み中...",
"examples": "例を読み込み中..."
"examples": "例を読み込み中...",
"versions": "バージョンを読み込み中..."
},
"versions": {
"heading": "モデルバージョン",
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
"media": {
"placeholder": "プレビューなし"
},
"labels": {
"unnamed": "名前のないバージョン",
"noDetails": "追加情報なし"
},
"badges": {
"current": "現在のバージョン",
"inLibrary": "ライブラリにあります",
"newer": "新しいバージョン",
"ignored": "無視中"
},
"actions": {
"download": "ダウンロード",
"delete": "削除",
"ignore": "無視",
"unignore": "無視を解除",
"resumeModelUpdates": "このモデルの更新を再開",
"ignoreModelUpdates": "このモデルの更新を無視",
"viewLocalVersions": "ローカルの全バージョンを表示",
"viewLocalTooltip": "近日対応予定"
},
"empty": "このモデルにはまだバージョン履歴がありません。",
"error": "バージョンの読み込みに失敗しました。",
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
"confirm": {
"delete": "このバージョンをライブラリから削除しますか?"
},
"toast": {
"modelIgnored": "このモデルの更新は無視されます",
"modelResumed": "更新の監視を再開しました",
"versionIgnored": "このバージョンの更新は無視されます",
"versionUnignored": "バージョンを再度有効にしました",
"versionDeleted": "バージョンを削除しました"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "LoRAをワークフローに送信できませんでした",
"recipeAdded": "レシピがワークフローに追加されました",
"recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした"
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noTargetNodeSelected": "ターゲットノードが選択されていません"
},
"nodeSelector": {
"recipe": "レシピ",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "更新確認",
"notificationsTitle": "通知センター",
"tabs": {
"updates": "更新",
"messages": "メッセージ"
},
"updateAvailable": "更新が利用可能",
"noChangelogAvailable": "詳細な変更ログは利用できません。詳細はGitHubでご確認ください。",
"currentVersion": "現在のバージョン",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "警告:ナイトリービルドには実験的機能が含まれており、不安定な場合があります。",
"enable": "ナイトリー更新を有効にする"
},
"banners": {
"recent": "最近の通知",
"empty": "最近のバナーはありません。",
"shown": "{time} に表示",
"dismissed": "{time} に非表示",
"active": "アクティブ"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "{count} 件のモデルのコンテンツレーティングを {level} に設定しました",
"bulkContentRatingPartial": "{success} 件のモデルのコンテンツレーティングを {level} に設定、{failed} 件は失敗しました",
"bulkContentRatingFailed": "選択したモデルのコンテンツレーティングを更新できませんでした",
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
"bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません",
"bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました",
"bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}",
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
"renameFailed": "ファイル名の変更に失敗しました:{message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "ダウンロードの一時停止に失敗しました:{error}",
"downloadResumed": "ダウンロードが再開されました",
"resumeFailed": "ダウンロードの再開に失敗しました:{error}",
"downloadStopped": "ダウンロードをキャンセルしました",
"stopFailed": "ダウンロードのキャンセルに失敗しました:{error}",
"deleted": "例画像が削除されました",
"deleteFailed": "例画像の削除に失敗しました",
"setPreviewFailed": "プレビュー画像の設定に失敗しました"

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 바이트",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "Checkpoint 이름 복사됨",
"toggleBlur": "블러 토글",
"show": "보기",
"openExampleImages": "예시 이미지 폴더 열기"
"openExampleImages": "예시 이미지 폴더 열기",
"replacePreview": "미리보기 교체",
"copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사",
"sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
},
"nsfw": {
"matureContent": "성인 콘텐츠",
@@ -115,12 +120,17 @@
"updateFailed": "즐겨찾기 상태 업데이트 실패"
},
"sendToWorkflow": {
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능"
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능",
"missingPath": "이 카드의 모델 경로를 확인할 수 없습니다"
},
"exampleImages": {
"checkError": "예시 이미지 확인 중 오류",
"missingHash": "모델 해시 정보가 없습니다.",
"noRemoteImagesAvailable": "Civitai에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
},
"badges": {
"update": "업데이트",
"updateAvailable": "업데이트 가능"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "예시 이미지를 다운로드하기 전에 다운로드 위치를 설정하세요.",
"unavailable": "예시 이미지 다운로드는 아직 사용할 수 없습니다. 페이지 로딩이 완료된 후 다시 시도하세요."
},
"checkModelUpdates": {
"label": "업데이트 확인",
"loading": "{type} 업데이트를 확인 중...",
"success": "{type} 업데이트 {count}개를 찾았습니다",
"none": "모든 {type}가 최신 상태입니다",
"error": "{type} 업데이트 확인 실패: {message}"
},
"cleanupExampleImages": {
"label": "예시 이미지 폴더 정리",
"success": "{count}개의 폴더가 삭제 폴더로 이동되었습니다",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "업데이트 확인",
"notifications": "알림",
"support": "지원"
}
},
@@ -203,7 +221,8 @@
"exampleImages": "예시 이미지",
"misc": "기타",
"metadataArchive": "메타데이터 아카이브 데이터베이스",
"proxySettings": "프록시 설정"
"proxySettings": "프록시 설정",
"priorityTags": "우선순위 태그"
},
"contentFiltering": {
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
@@ -224,21 +243,31 @@
},
"displayDensityHelp": "한 줄에 표시할 카드 수를 선택하세요:",
"displayDensityDetails": {
"default": "기본: 5개 (1080p), 6개 (2K), 8개 (4K)",
"medium": "중간: 6개 (1080p), 7개 (2K), 9개 (4K)",
"compact": "조밀: 7개 (1080p), 8개 (2K), 10개 (4K)"
"default": "5개 (1080p), 6개 (2K), 8개 (4K)",
"medium": "6개 (1080p), 7개 (2K), 9개 (4K)",
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
},
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
"showFolderSidebar": "폴더 사이드바 표시",
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
"cardInfoDisplay": "카드 정보 표시",
"cardInfoDisplayOptions": {
"always": "항상 표시",
"hover": "호버 시 표시"
},
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요:",
"cardInfoDisplayDetails": {
"always": "항상 표시: 헤더와 푸터가 항상 보입니다",
"hover": "호버 시 표시: 카드에 마우스를 올렸을 때만 헤더와 푸터가 나타납니다"
}
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
"modelCardFooterAction": "모델 카드 버튼 동작",
"modelCardFooterActionOptions": {
"exampleImages": "예시 이미지 열기",
"replacePreview": "미리보기 교체"
},
"modelCardFooterActionHelp": "카드 우측 하단 버튼이 수행할 작업을 선택하세요",
"modelNameDisplay": "모델명 표시",
"modelNameDisplayOptions": {
"modelName": "모델명",
"fileName": "파일명"
},
"modelNameDisplayHelp": "모델 카드 하단에 표시할 내용을 선택하세요"
},
"folderSettings": {
"activeLibrary": "활성 라이브러리",
@@ -345,6 +374,26 @@
"proxyPassword": "비밀번호 (선택사항)",
"proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)"
},
"priorityTags": {
"title": "우선순위 태그",
"description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "우선순위 태그 도움말 열기",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "체크포인트",
"embedding": "임베딩"
},
"saveSuccess": "우선순위 태그가 업데이트되었습니다.",
"saveError": "우선순위 태그를 업데이트하지 못했습니다.",
"loadingSuggestions": "추천을 불러오는 중...",
"validation": {
"missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.",
"missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.",
"duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.",
"unknown": "잘못된 우선순위 태그 구성입니다."
}
}
},
"loras": {
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "모델 목록 새로고침",
"quick": "빠른 새로고침 (증분)",
"full": "전체 재구성 (완전)"
"quick": "변경 사항 동기화",
"quickTooltip": "새로운 모델 파일이나 누락된 파일을 찾아 목록을 최신 상태로 유지합니다.",
"full": "캐시 재구성",
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
},
"fetch": {
"title": "Civitai에서 메타데이터 가져오기",
@@ -385,6 +436,13 @@
"favorites": {
"title": "즐겨찾기만 보기",
"action": "즐겨찾기"
},
"updates": {
"title": "업데이트 가능한 모델만 표시",
"action": "업데이트",
"menuLabel": "업데이트 옵션 표시",
"check": "업데이트 확인",
"checkTooltip": "업데이트 확인에는 시간이 걸릴 수 있습니다."
}
},
"bulkOperations": {
@@ -396,6 +454,7 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"checkUpdates": "선택 항목 업데이트 확인",
"moveAll": "모두 폴더로 이동",
"autoOrganize": "자동 정리 선택",
"deleteAll": "모든 모델 삭제",
@@ -529,13 +588,19 @@
"title": "Embedding 모델"
},
"sidebar": {
"modelRoot": "모델 루트",
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
"pinSidebar": "사이드바 고정",
"unpinSidebar": "사이드바 고정 해제",
"switchToListView": "목록 보기로 전환",
"switchToTreeView": "트리 보기로 전환",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다"
"recursiveOn": "하위 폴더 검색",
"recursiveOff": "현재 폴더만 검색",
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
}
},
"statistics": {
"title": "통계",
@@ -610,6 +675,14 @@
"downloadedPreview": "미리보기 이미지 다운로드됨",
"downloadingFile": "{type} 파일 다운로드 중",
"finalizing": "다운로드 완료 중..."
},
"progress": {
"currentFile": "현재 파일:",
"downloading": "다운로드 중: {name}",
"transferred": "다운로드됨: {downloaded} / {total}",
"transferredSimple": "다운로드됨: {downloaded}",
"transferredUnknown": "다운로드됨: --",
"speed": "속도: {speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "개의 모델이 영구적으로 삭제됩니다.",
"action": "모두 삭제"
},
"checkUpdates": {
"title": "{type} 전체 업데이트를 확인할까요?",
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
"tip": "나눠서 진행하고 싶다면 벌크 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
"action": "전체 확인"
},
"bulkAddTags": {
"title": "여러 모델에 태그 추가",
"description": "다음에 태그를 추가합니다:",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "예시",
"description": "모델 설명",
"recipes": "레시피"
"recipes": "레시피",
"versions": "버전"
},
"loading": {
"exampleImages": "예시 이미지 로딩 중...",
"description": "모델 설명 로딩 중...",
"recipes": "레시피 로딩 중...",
"examples": "예시 로딩 중..."
"examples": "예시 로딩 중...",
"versions": "버전 로딩 중..."
},
"versions": {
"heading": "모델 버전",
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
"media": {
"placeholder": "미리보기 없음"
},
"labels": {
"unnamed": "이름 없는 버전",
"noDetails": "추가 정보 없음"
},
"badges": {
"current": "현재 버전",
"inLibrary": "라이브러리에 있음",
"newer": "최신 버전",
"ignored": "무시됨"
},
"actions": {
"download": "다운로드",
"delete": "삭제",
"ignore": "무시",
"unignore": "무시 해제",
"resumeModelUpdates": "이 모델 업데이트 재개",
"ignoreModelUpdates": "이 모델 업데이트 무시",
"viewLocalVersions": "로컬 버전 모두 보기",
"viewLocalTooltip": "곧 제공 예정"
},
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
"error": "버전을 불러오지 못했습니다.",
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
"confirm": {
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
},
"toast": {
"modelIgnored": "이 모델의 업데이트가 무시됩니다",
"modelResumed": "업데이트 추적이 재개되었습니다",
"versionIgnored": "이 버전의 업데이트가 무시됩니다",
"versionUnignored": "버전이 다시 활성화되었습니다",
"versionDeleted": "버전이 삭제되었습니다"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "LoRA를 워크플로로 전송하지 못했습니다",
"recipeAdded": "레시피가 워크플로에 추가되었습니다",
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다"
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다"
},
"nodeSelector": {
"recipe": "레시피",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "업데이트 확인",
"notificationsTitle": "알림 센터",
"tabs": {
"updates": "업데이트",
"messages": "메시지"
},
"updateAvailable": "업데이트 사용 가능",
"noChangelogAvailable": "상세한 변경 로그가 없습니다. 더 많은 정보는 GitHub를 확인하세요.",
"currentVersion": "현재 버전",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "경고: 나이틀리 빌드는 실험적 기능을 포함할 수 있으며 불안정할 수 있습니다.",
"enable": "나이틀리 업데이트 활성화"
},
"banners": {
"recent": "최근 알림",
"empty": "최근 배너가 없습니다.",
"shown": "{time}에 표시",
"dismissed": "{time}에 닫힘",
"active": "활성"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "{count}개 모델의 콘텐츠 등급을 {level}(으)로 설정했습니다",
"bulkContentRatingPartial": "{success}개 모델의 콘텐츠 등급을 {level}(으)로 설정했고, {failed}개는 실패했습니다",
"bulkContentRatingFailed": "선택한 모델의 콘텐츠 등급을 업데이트하지 못했습니다",
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
"bulkUpdatesMissing": "선택한 {type}이 Civitai 업데이트에 연결되어 있지 않습니다",
"bulkUpdatesPartialMissing": "Civitai 링크가 없는 {missing}개의 {type}을 건너뛰었습니다",
"bulkUpdatesFailed": "선택한 {type}의 업데이트 확인에 실패했습니다: {message}",
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
"renameFailed": "파일 이름 변경 실패: {message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "다운로드 일시정지 실패: {error}",
"downloadResumed": "다운로드가 재개되었습니다",
"resumeFailed": "다운로드 재개 실패: {error}",
"downloadStopped": "다운로드가 취소되었습니다",
"stopFailed": "다운로드 취소 실패: {error}",
"deleted": "예시 이미지가 삭제되었습니다",
"deleteFailed": "예시 이미지 삭제 실패",
"setPreviewFailed": "미리보기 이미지 설정 실패"

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 Байт",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "Имя checkpoint скопировано",
"toggleBlur": "Переключить размытие",
"show": "Показать",
"openExampleImages": "Открыть папку с примерами"
"openExampleImages": "Открыть папку с примерами",
"replacePreview": "Заменить превью",
"copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding",
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
},
"nsfw": {
"matureContent": "Контент для взрослых",
@@ -115,12 +120,17 @@
"updateFailed": "Не удалось обновить статус избранного"
},
"sendToWorkflow": {
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована"
"checkpointNotImplemented": "Отправка checkpoint в workflow - функция будет реализована",
"missingPath": "Невозможно определить путь модели для этой карточки"
},
"exampleImages": {
"checkError": "Ошибка проверки примеров изображений",
"missingHash": "Отсутствует хеш модели.",
"noRemoteImagesAvailable": "Нет удаленных примеров изображений для этой модели на Civitai"
},
"badges": {
"update": "Обновление",
"updateAvailable": "Доступно обновление"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "Укажите место загрузки перед загрузкой примеров изображений.",
"unavailable": "Загрузка примеров изображений пока недоступна. Попробуйте снова после полной загрузки страницы."
},
"checkModelUpdates": {
"label": "Проверить обновления",
"loading": "Проверка обновлений для {type}...",
"success": "Найдено {count} обновлений для {type}",
"none": "Все {type} актуальны",
"error": "Не удалось проверить обновления для {type}: {message}"
},
"cleanupExampleImages": {
"label": "Очистить папки с примерами изображений",
"success": "Перемещено {count} папок в папку удалённых",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "Проверить обновления",
"notifications": "Уведомления",
"support": "Поддержка"
}
},
@@ -203,7 +221,8 @@
"exampleImages": "Примеры изображений",
"misc": "Разное",
"metadataArchive": "Архив метаданных",
"proxySettings": "Настройки прокси"
"proxySettings": "Настройки прокси",
"priorityTags": "Приоритетные теги"
},
"contentFiltering": {
"blurNsfwContent": "Размывать NSFW контент",
@@ -224,21 +243,31 @@
},
"displayDensityHelp": "Выберите количество карточек для отображения в ряду:",
"displayDensityDetails": {
"default": "По умолчанию: 5 (1080p), 6 (2K), 8 (4K)",
"medium": "Средняя: 6 (1080p), 7 (2K), 9 (4K)",
"compact": "Компактная: 7 (1080p), 8 (2K), 10 (4K)"
"default": "5 (1080p), 6 (2K), 8 (4K)",
"medium": "6 (1080p), 7 (2K), 9 (4K)",
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
"showFolderSidebar": "Показывать боковую панель папок",
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
"cardInfoDisplay": "Отображение информации карточки",
"cardInfoDisplayOptions": {
"always": "Всегда видимо",
"hover": "Показать при наведении"
},
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий:",
"cardInfoDisplayDetails": {
"always": "Всегда видимо: Заголовки и подписи всегда видны",
"hover": "Показать при наведении: Заголовки и подписи появляются только при наведении на карточку"
}
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
"modelCardFooterAction": "Действие кнопки карточки модели",
"modelCardFooterActionOptions": {
"exampleImages": "Открыть примеры изображений",
"replacePreview": "Заменить превью"
},
"modelCardFooterActionHelp": "Выберите, что делает кнопка в правом нижнем углу карточки",
"modelNameDisplay": "Отображение названия модели",
"modelNameDisplayOptions": {
"modelName": "Название модели",
"fileName": "Имя файла"
},
"modelNameDisplayHelp": "Выберите, что отображать в нижней части карточки модели"
},
"folderSettings": {
"activeLibrary": "Активная библиотека",
@@ -345,6 +374,26 @@
"proxyPassword": "Пароль (необязательно)",
"proxyPasswordPlaceholder": "пароль",
"proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)"
},
"priorityTags": {
"title": "Приоритетные теги",
"description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "Открыть справку по приоритетным тегам",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Чекпойнт",
"embedding": "Эмбеддинг"
},
"saveSuccess": "Приоритетные теги обновлены.",
"saveError": "Не удалось обновить приоритетные теги.",
"loadingSuggestions": "Загрузка подсказок...",
"validation": {
"missingClosingParen": "В записи {index} отсутствует закрывающая скобка.",
"missingCanonical": "Запись {index} должна содержать каноническое имя тега.",
"duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.",
"unknown": "Недопустимая конфигурация приоритетных тегов."
}
}
},
"loras": {
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "Обновить список моделей",
"quick": "Быстрое обновление (инкрементальное)",
"full": "Полная перестройка (полное)"
"quick": "Синхронизировать изменения",
"quickTooltip": "Находит новые или отсутствующие файлы моделей, чтобы список оставался актуальным.",
"full": "Перестроить кэш",
"fullTooltip": "Перечитывает все данные моделей из файлов метаданных — используйте, если библиотека выглядит устаревшей или после ручных правок."
},
"fetch": {
"title": "Получить метаданные с Civitai",
@@ -385,6 +436,13 @@
"favorites": {
"title": "Показать только избранное",
"action": "Избранное"
},
"updates": {
"title": "Показывать только модели с доступными обновлениями",
"action": "Обновления",
"menuLabel": "Показать параметры обновления",
"check": "Проверить обновления",
"checkTooltip": "Проверка может занять время."
}
},
"bulkOperations": {
@@ -396,6 +454,7 @@
"setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"checkUpdates": "Проверить обновления для выбранных",
"moveAll": "Переместить все в папку",
"autoOrganize": "Автоматически организовать выбранные",
"deleteAll": "Удалить все модели",
@@ -529,13 +588,19 @@
"title": "Модели Embedding"
},
"sidebar": {
"modelRoot": "Корень моделей",
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель",
"switchToListView": "Переключить на вид списка",
"switchToTreeView": "Переключить на древовидный вид",
"collapseAllDisabled": "Недоступно в виде списка"
"recursiveOn": "Искать во вложенных папках",
"recursiveOff": "Искать только в текущей папке",
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
}
},
"statistics": {
"title": "Статистика",
@@ -610,6 +675,14 @@
"downloadedPreview": "Превью изображение загружено",
"downloadingFile": "Загрузка файла {type}",
"finalizing": "Завершение загрузки..."
},
"progress": {
"currentFile": "Текущий файл:",
"downloading": "Скачивается: {name}",
"transferred": "Скачано: {downloaded} / {total}",
"transferredSimple": "Скачано: {downloaded}",
"transferredUnknown": "Скачано: --",
"speed": "Скорость: {speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "моделей будут удалены навсегда.",
"action": "Удалить все"
},
"checkUpdates": {
"title": "Проверить обновления для всех {typePlural}?",
"message": "Будут проверены обновления для всех {typePlural} в вашей библиотеке. Для больших коллекций это может занять немного больше времени.",
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
"action": "Проверить всё"
},
"bulkAddTags": {
"title": "Добавить теги к нескольким моделям",
"description": "Добавить теги к",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "Примеры",
"description": "Описание модели",
"recipes": "Рецепты"
"recipes": "Рецепты",
"versions": "Версии"
},
"loading": {
"exampleImages": "Загрузка примеров изображений...",
"description": "Загрузка описания модели...",
"recipes": "Загрузка рецептов...",
"examples": "Загрузка примеров..."
"examples": "Загрузка примеров...",
"versions": "Загрузка версий..."
},
"versions": {
"heading": "Версии модели",
"copy": "Управляйте всеми версиями этой модели в одном месте.",
"media": {
"placeholder": "Нет превью"
},
"labels": {
"unnamed": "Версия без названия",
"noDetails": "Дополнительная информация отсутствует"
},
"badges": {
"current": "Текущая версия",
"inLibrary": "В библиотеке",
"newer": "Более новая версия",
"ignored": "Игнорируется"
},
"actions": {
"download": "Скачать",
"delete": "Удалить",
"ignore": "Игнорировать",
"unignore": "Перестать игнорировать",
"resumeModelUpdates": "Возобновить обновления для этой модели",
"ignoreModelUpdates": "Игнорировать обновления для этой модели",
"viewLocalVersions": "Показать все локальные версии",
"viewLocalTooltip": "Скоро появится"
},
"empty": "Для этой модели пока нет истории версий.",
"error": "Не удалось загрузить версии.",
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",
"confirm": {
"delete": "Удалить эту версию из библиотеки?"
},
"toast": {
"modelIgnored": "Обновления для этой модели игнорируются",
"modelResumed": "Отслеживание обновлений возобновлено",
"versionIgnored": "Обновления для этой версии игнорируются",
"versionUnignored": "Версия снова активна",
"versionDeleted": "Версия удалена"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "Не удалось отправить LoRA в workflow",
"recipeAdded": "Рецепт добавлен в workflow",
"recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow"
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noTargetNodeSelected": "Целевой узел не выбран"
},
"nodeSelector": {
"recipe": "Рецепт",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "Проверить обновления",
"notificationsTitle": "Центр уведомлений",
"tabs": {
"updates": "Обновления",
"messages": "Сообщения"
},
"updateAvailable": "Доступно обновление",
"noChangelogAvailable": "Подробный список изменений недоступен. Проверьте GitHub для получения дополнительной информации.",
"currentVersion": "Текущая версия",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "Предупреждение: Ночные сборки могут содержать экспериментальные функции и могут быть нестабильными.",
"enable": "Включить ночные обновления"
},
"banners": {
"recent": "Недавние уведомления",
"empty": "Недавних баннеров нет.",
"shown": "Показано {time}",
"dismissed": "Закрыто {time}",
"active": "Активно"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "Рейтинг контента установлен на {level} для {count} модель(ей)",
"bulkContentRatingPartial": "Рейтинг контента {level} установлен для {success} модель(ей), {failed} не удалось",
"bulkContentRatingFailed": "Не удалось обновить рейтинг контента для выбранных моделей",
"bulkUpdatesChecking": "Проверка обновлений для выбранных {type}...",
"bulkUpdatesSuccess": "Доступны обновления для {count} выбранных {type}",
"bulkUpdatesNone": "Обновления для выбранных {type} не найдены",
"bulkUpdatesMissing": "Выбранные {type} не привязаны к обновлениям Civitai",
"bulkUpdatesPartialMissing": "Пропущено {missing} выбранных {type} без привязки Civitai",
"bulkUpdatesFailed": "Не удалось проверить обновления для выбранных {type}: {message}",
"invalidCharactersRemoved": "Недопустимые символы удалены из имени файла",
"filenameCannotBeEmpty": "Имя файла не может быть пустым",
"renameFailed": "Не удалось переименовать файл: {message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "Не удалось приостановить загрузку: {error}",
"downloadResumed": "Загрузка возобновлена",
"resumeFailed": "Не удалось возобновить загрузку: {error}",
"downloadStopped": "Загрузка отменена",
"stopFailed": "Не удалось отменить загрузку: {error}",
"deleted": "Пример изображения удален",
"deleteFailed": "Не удалось удалить пример изображения",
"setPreviewFailed": "Не удалось установить превью изображение"

View File

@@ -26,19 +26,13 @@
"english": "English",
"chinese_simplified": "中文(简体)",
"chinese_traditional": "中文(繁体)",
"russian": "俄语",
"german": "德语",
"japanese": "日语",
"korean": "韩语",
"french": "法语",
"spanish": "西班牙语",
"Hebrew": "עברית",
"russian": "Русский",
"german": "Deutsch",
"japanese": "日本語",
"korean": "한국어",
"french": "Français",
"spanish": "Español"
"spanish": "Español",
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 字节",
@@ -107,7 +101,12 @@
"checkpointNameCopied": "检查点名称已复制",
"toggleBlur": "切换模糊",
"show": "显示",
"openExampleImages": "打开示例图片文件夹"
"openExampleImages": "打开示例图片文件夹",
"replacePreview": "替换预览",
"copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称",
"sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
},
"nsfw": {
"matureContent": "成熟内容",
@@ -121,12 +120,17 @@
"updateFailed": "收藏状态更新失败"
},
"sendToWorkflow": {
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现"
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现",
"missingPath": "无法确定此卡片的模型路径"
},
"exampleImages": {
"checkError": "检查示例图片时出错",
"missingHash": "缺少模型哈希信息。",
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
},
"badges": {
"update": "更新",
"updateAvailable": "有可用更新"
}
},
"globalContextMenu": {
@@ -135,6 +139,13 @@
"missingPath": "请先设置下载位置后再下载示例图片。",
"unavailable": "示例图片下载当前不可用。请在页面加载完成后重试。"
},
"checkModelUpdates": {
"label": "检查更新",
"loading": "正在检查 {type} 更新...",
"success": "找到 {count} 条 {type} 更新",
"none": "所有 {type} 均已是最新版本",
"error": "检查 {type} 更新失败:{message}"
},
"cleanupExampleImages": {
"label": "清理示例图片文件夹",
"success": "已将 {count} 个文件夹移动到已删除文件夹",
@@ -187,6 +198,7 @@
},
"actions": {
"checkUpdates": "检查更新",
"notifications": "通知",
"support": "支持"
}
},
@@ -209,7 +221,8 @@
"exampleImages": "示例图片",
"misc": "其他",
"metadataArchive": "元数据归档数据库",
"proxySettings": "代理设置"
"proxySettings": "代理设置",
"priorityTags": "优先标签"
},
"contentFiltering": {
"blurNsfwContent": "模糊 NSFW 内容",
@@ -230,21 +243,31 @@
},
"displayDensityHelp": "选择每行显示卡片数量:",
"displayDensityDetails": {
"default": "默认:51080p62K84K",
"medium": "中等:61080p72K94K",
"compact": "紧凑:71080p82K104K"
"default": "51080p62K84K",
"medium": "61080p72K94K",
"compact": "71080p82K104K"
},
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
"showFolderSidebar": "显示文件夹侧边栏",
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
"cardInfoDisplay": "卡片信息显示",
"cardInfoDisplayOptions": {
"always": "始终可见",
"hover": "悬停时显示"
},
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
"cardInfoDisplayDetails": {
"always": "始终可见:标题和底部始终显示",
"hover": "悬停时显示:仅在悬停卡片时显示标题和底部"
}
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
"modelCardFooterAction": "模型卡片按钮操作",
"modelCardFooterActionOptions": {
"exampleImages": "打开示例图片",
"replacePreview": "替换预览"
},
"modelCardFooterActionHelp": "选择右下角卡片按钮的功能",
"modelNameDisplay": "模型名称显示",
"modelNameDisplayOptions": {
"modelName": "模型名称",
"fileName": "文件名"
},
"modelNameDisplayHelp": "选择在模型卡片底部显示的内容"
},
"folderSettings": {
"activeLibrary": "活动库",
@@ -351,6 +374,26 @@
"proxyPassword": "密码 (可选)",
"proxyPasswordPlaceholder": "密码",
"proxyPasswordHelp": "代理认证的密码 (如果需要)"
},
"priorityTags": {
"title": "优先标签",
"description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "打开优先标签帮助",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "优先标签已更新。",
"saveError": "优先标签更新失败。",
"loadingSuggestions": "正在加载建议...",
"validation": {
"missingClosingParen": "条目 {index} 缺少右括号。",
"missingCanonical": "条目 {index} 必须包含规范标签名称。",
"duplicateCanonical": "规范标签 \"{tag}\" 出现多次。",
"unknown": "优先标签配置无效。"
}
}
},
"loras": {
@@ -369,8 +412,10 @@
},
"refresh": {
"title": "刷新模型列表",
"quick": "快速刷新(增量)",
"full": "完全重建(完整)"
"quick": "同步变更",
"quickTooltip": "扫描新的或缺失的模型文件,保持列表最新。",
"full": "重建缓存",
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
},
"fetch": {
"title": "从 Civitai 获取元数据",
@@ -391,20 +436,28 @@
"favorites": {
"title": "仅显示收藏",
"action": "收藏"
},
"updates": {
"title": "仅显示可用更新的模型",
"action": "更新",
"menuLabel": "显示更新选项",
"check": "检查更新",
"checkTooltip": "检查更新可能耗时。"
}
},
"bulkOperations": {
"selected": "已选中 {count} 项",
"selectedSuffix": "已选中",
"viewSelected": "查看已选中",
"addTags": "为所添加标签",
"setBaseModel": "为所设置基础模型",
"setContentRating": "为全部设置内容评级",
"copyAll": "复制全部语法",
"refreshAll": "刷新全部元数据",
"moveAll": "全部移动到文件夹",
"addTags": "为所选中添加标签",
"setBaseModel": "为所选中设置基础模型",
"setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"checkUpdates": "检查所选更新",
"moveAll": "移动所选中到文件夹",
"autoOrganize": "自动整理所选模型",
"deleteAll": "删除所有模型",
"deleteAll": "删除选中模型",
"clear": "清除选择",
"autoOrganizeProgress": {
"initializing": "正在初始化自动整理...",
@@ -535,13 +588,19 @@
"title": "Embedding 模型"
},
"sidebar": {
"modelRoot": "模型根目录",
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏",
"switchToListView": "切换到列表视图",
"switchToTreeView": "切换到树状视图",
"collapseAllDisabled": "列表视图下不可用"
"recursiveOn": "搜索子文件夹",
"recursiveOff": "仅搜索当前文件夹",
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用",
"dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。"
}
},
"statistics": {
"title": "统计",
@@ -616,6 +675,14 @@
"downloadedPreview": "预览图片已下载",
"downloadingFile": "正在下载 {type} 文件",
"finalizing": "正在完成下载..."
},
"progress": {
"currentFile": "当前文件:",
"downloading": "下载中:{name}",
"transferred": "已下载:{downloaded} / {total}",
"transferredSimple": "已下载:{downloaded}",
"transferredUnknown": "已下载:--",
"speed": "速度:{speed}"
}
},
"move": {
@@ -663,6 +730,12 @@
"countMessage": "模型将被永久删除。",
"action": "全部删除"
},
"checkUpdates": {
"title": "检查所有 {type} 的更新?",
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
"action": "检查全部"
},
"bulkAddTags": {
"title": "批量添加标签",
"description": "为多个模型添加标签",
@@ -799,13 +872,55 @@
"tabs": {
"examples": "示例",
"description": "模型描述",
"recipes": "配方"
"recipes": "配方",
"versions": "版本"
},
"loading": {
"exampleImages": "正在加载示例图片...",
"description": "正在加载模型描述...",
"recipes": "正在加载配方...",
"examples": "正在加载示例..."
"examples": "正在加载示例...",
"versions": "正在加载版本..."
},
"versions": {
"heading": "模型版本",
"copy": "在一个位置管理该模型的所有版本。",
"media": {
"placeholder": "无预览"
},
"labels": {
"unnamed": "未命名版本",
"noDetails": "暂无更多信息"
},
"badges": {
"current": "当前版本",
"inLibrary": "已在库中",
"newer": "较新的版本",
"ignored": "已忽略"
},
"actions": {
"download": "下载",
"delete": "删除",
"ignore": "忽略",
"unignore": "取消忽略",
"resumeModelUpdates": "继续跟踪该模型的更新",
"ignoreModelUpdates": "忽略该模型的更新",
"viewLocalVersions": "查看所有本地版本",
"viewLocalTooltip": "敬请期待"
},
"empty": "该模型还没有版本历史。",
"error": "加载版本失败。",
"missingModelId": "该模型缺少 Civitai 模型 ID。",
"confirm": {
"delete": "从库中删除此版本?"
},
"toast": {
"modelIgnored": "已忽略该模型的更新",
"modelResumed": "已恢复更新跟踪",
"versionIgnored": "已忽略该版本的更新",
"versionUnignored": "已重新启用该版本",
"versionDeleted": "版本已删除"
}
}
}
},
@@ -912,7 +1027,9 @@
"loraFailedToSend": "发送 LoRA 到工作流失败",
"recipeAdded": "配方已追加到工作流",
"recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败"
"recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noTargetNodeSelected": "未选择目标节点"
},
"nodeSelector": {
"recipe": "配方",
@@ -957,6 +1074,11 @@
},
"update": {
"title": "检查更新",
"notificationsTitle": "通知中心",
"tabs": {
"updates": "更新",
"messages": "消息"
},
"updateAvailable": "更新可用",
"noChangelogAvailable": "没有详细的更新日志可用。请查看 GitHub 以获取更多信息。",
"currentVersion": "当前版本",
@@ -988,6 +1110,13 @@
"nightly": {
"warning": "警告Nightly 版本可能包含实验性功能,可能不稳定。",
"enable": "启用 Nightly 更新"
},
"banners": {
"recent": "最近的通知",
"empty": "暂无最近的横幅通知。",
"shown": "{time} 显示",
"dismissed": "{time} 关闭",
"active": "仍在显示"
}
},
"support": {
@@ -1107,6 +1236,12 @@
"bulkContentRatingSet": "已将 {count} 个模型的内容评级设置为 {level}",
"bulkContentRatingPartial": "已将 {success} 个模型的内容评级设置为 {level}{failed} 个失败",
"bulkContentRatingFailed": "未能更新所选模型的内容评级",
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
"bulkUpdatesNone": "所选 {type} 未发现更新",
"bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新",
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}",
"bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}",
"invalidCharactersRemoved": "文件名中的无效字符已移除",
"filenameCannotBeEmpty": "文件名不能为空",
"renameFailed": "重命名文件失败:{message}",
@@ -1216,6 +1351,8 @@
"pauseFailed": "暂停下载失败:{error}",
"downloadResumed": "下载已恢复",
"resumeFailed": "恢复下载失败:{error}",
"downloadStopped": "下载已取消",
"stopFailed": "取消下载失败:{error}",
"deleted": "示例图片已删除",
"deleteFailed": "删除示例图片失败",
"setPreviewFailed": "设置预览图片失败"
@@ -1264,10 +1401,10 @@
"seconds": "秒后刷新"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
"title": "LM 浏览器插件限时优惠 ⚡",
"content": "来爱发电为Lora Manager项目发电支持项目持续开发的同时获取浏览器插件验证码按季支付更优惠支付宝/微信方便支付。感谢支持!🚀",
"supportCta": "为LM发电",
"learnMore": "浏览器插件教程"
}
}
}

View File

@@ -32,7 +32,7 @@
"korean": "한국어",
"french": "Français",
"spanish": "Español",
"Hebrew": "עברית"
"Hebrew": "עברית"
},
"fileSize": {
"zero": "0 位元組",
@@ -101,7 +101,12 @@
"checkpointNameCopied": "Checkpoint 名稱已複製",
"toggleBlur": "切換模糊",
"show": "顯示",
"openExampleImages": "開啟範例圖片資料夾"
"openExampleImages": "開啟範例圖片資料夾",
"replacePreview": "更換預覽圖",
"copyCheckpointName": "複製檢查點名稱",
"copyEmbeddingName": "複製嵌入名稱",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
},
"nsfw": {
"matureContent": "成熟內容",
@@ -115,12 +120,17 @@
"updateFailed": "更新收藏狀態失敗"
},
"sendToWorkflow": {
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現"
"checkpointNotImplemented": "傳送 checkpoint 到工作流 - 功能尚未實現",
"missingPath": "無法確定此卡片的模型路徑"
},
"exampleImages": {
"checkError": "檢查範例圖片時發生錯誤",
"missingHash": "缺少模型雜湊資訊。",
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
},
"badges": {
"update": "更新",
"updateAvailable": "有可用更新"
}
},
"globalContextMenu": {
@@ -129,6 +139,13 @@
"missingPath": "請先設定下載位置再下載範例圖片。",
"unavailable": "範例圖片下載目前尚不可用。請在頁面載入完成後再試一次。"
},
"checkModelUpdates": {
"label": "檢查更新",
"loading": "正在檢查 {type} 更新...",
"success": "找到 {count} 個 {type} 更新",
"none": "所有 {type} 都是最新版本",
"error": "檢查 {type} 更新失敗:{message}"
},
"cleanupExampleImages": {
"label": "清理範例圖片資料夾",
"success": "已將 {count} 個資料夾移至已刪除資料夾",
@@ -181,6 +198,7 @@
},
"actions": {
"checkUpdates": "檢查更新",
"notifications": "通知",
"support": "支援"
}
},
@@ -203,7 +221,8 @@
"exampleImages": "範例圖片",
"misc": "其他",
"metadataArchive": "中繼資料封存資料庫",
"proxySettings": "代理設定"
"proxySettings": "代理設定",
"priorityTags": "優先標籤"
},
"contentFiltering": {
"blurNsfwContent": "模糊 NSFW 內容",
@@ -224,21 +243,31 @@
},
"displayDensityHelp": "選擇每行顯示卡片數量:",
"displayDensityDetails": {
"default": "預設:51080p、62K、84K",
"medium": "中等:61080p、72K、94K",
"compact": "緊湊:71080p、82K、104K"
"default": "51080p、62K、84K",
"medium": "61080p、72K、94K",
"compact": "71080p、82K、104K"
},
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
"showFolderSidebar": "顯示資料夾側邊欄",
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
"cardInfoDisplay": "卡片資訊顯示",
"cardInfoDisplayOptions": {
"always": "永遠顯示",
"hover": "滑鼠懸停顯示"
},
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
"cardInfoDisplayDetails": {
"always": "永遠顯示:標題與頁腳始終可見",
"hover": "滑鼠懸停顯示:標題與頁腳僅在滑鼠懸停時顯示"
}
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
"modelCardFooterAction": "模型卡片按鈕操作",
"modelCardFooterActionOptions": {
"exampleImages": "開啟範例圖片",
"replacePreview": "更換預覽圖"
},
"modelCardFooterActionHelp": "選擇右下角卡片按鈕的功能",
"modelNameDisplay": "模型名稱顯示",
"modelNameDisplayOptions": {
"modelName": "模型名稱",
"fileName": "檔案名稱"
},
"modelNameDisplayHelp": "選擇在模型卡片底部顯示的內容"
},
"folderSettings": {
"activeLibrary": "使用中的資料庫",
@@ -345,6 +374,26 @@
"proxyPassword": "密碼(選填)",
"proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "代理驗證所需的密碼(如有需要)"
},
"priorityTags": {
"title": "優先標籤",
"description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "開啟優先標籤說明",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "優先標籤已更新。",
"saveError": "更新優先標籤失敗。",
"loadingSuggestions": "正在載入建議...",
"validation": {
"missingClosingParen": "項目 {index} 缺少右括號。",
"missingCanonical": "項目 {index} 必須包含正規標籤名稱。",
"duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。",
"unknown": "優先標籤設定無效。"
}
}
},
"loras": {
@@ -363,8 +412,10 @@
},
"refresh": {
"title": "重新整理模型列表",
"quick": "快速刷新(增量)",
"full": "完整重建(全部)"
"quick": "同步變更",
"quickTooltip": "掃描新的或缺少的模型檔案,讓清單保持最新。",
"full": "重建快取",
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
},
"fetch": {
"title": "從 Civitai 取得 metadata",
@@ -385,6 +436,13 @@
"favorites": {
"title": "僅顯示收藏",
"action": "收藏"
},
"updates": {
"title": "僅顯示可用更新的模型",
"action": "更新",
"menuLabel": "顯示更新選項",
"check": "檢查更新",
"checkTooltip": "檢查更新可能耗時。"
}
},
"bulkOperations": {
@@ -396,6 +454,7 @@
"setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"checkUpdates": "檢查所選更新",
"moveAll": "全部移動到資料夾",
"autoOrganize": "自動整理所選模型",
"deleteAll": "刪除全部模型",
@@ -529,13 +588,19 @@
"title": "Embedding 模型"
},
"sidebar": {
"modelRoot": "模型根目錄",
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄",
"switchToListView": "切換至列表檢視",
"switchToTreeView": "切換樹狀檢視",
"collapseAllDisabled": "列表檢視下不可用"
"switchToTreeView": "切換樹狀檢視",
"recursiveOn": "搜尋子資料夾",
"recursiveOff": "僅搜尋目前資料夾",
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用",
"dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。"
}
},
"statistics": {
"title": "統計",
@@ -610,6 +675,14 @@
"downloadedPreview": "已下載預覽圖片",
"downloadingFile": "正在下載 {type} 檔案",
"finalizing": "完成下載中..."
},
"progress": {
"currentFile": "目前檔案:",
"downloading": "下載中:{name}",
"transferred": "已下載:{downloaded} / {total}",
"transferredSimple": "已下載:{downloaded}",
"transferredUnknown": "已下載:--",
"speed": "速度:{speed}"
}
},
"move": {
@@ -657,6 +730,12 @@
"countMessage": "模型將被永久刪除。",
"action": "全部刪除"
},
"checkUpdates": {
"title": "要檢查所有 {type} 的更新嗎?",
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
"action": "全部檢查"
},
"bulkAddTags": {
"title": "新增標籤到多個模型",
"description": "新增標籤到",
@@ -793,13 +872,55 @@
"tabs": {
"examples": "範例圖片",
"description": "模型描述",
"recipes": "配方"
"recipes": "配方",
"versions": "版本"
},
"loading": {
"exampleImages": "載入範例圖片中...",
"description": "載入模型描述中...",
"recipes": "載入配方中...",
"examples": "載入範例中..."
"examples": "載入範例中...",
"versions": "載入版本中..."
},
"versions": {
"heading": "模型版本",
"copy": "在同一位置追蹤並管理此模型的所有版本。",
"media": {
"placeholder": "無預覽"
},
"labels": {
"unnamed": "未命名版本",
"noDetails": "沒有其他資訊"
},
"badges": {
"current": "目前版本",
"inLibrary": "已在庫中",
"newer": "較新版本",
"ignored": "已忽略"
},
"actions": {
"download": "下載",
"delete": "刪除",
"ignore": "忽略",
"unignore": "取消忽略",
"resumeModelUpdates": "恢復追蹤此模型的更新",
"ignoreModelUpdates": "忽略此模型的更新",
"viewLocalVersions": "檢視所有本地版本",
"viewLocalTooltip": "敬請期待"
},
"empty": "此模型尚無版本歷史。",
"error": "載入版本失敗。",
"missingModelId": "此模型缺少 Civitai 模型 ID。",
"confirm": {
"delete": "要從庫中刪除此版本嗎?"
},
"toast": {
"modelIgnored": "已忽略此模型的更新",
"modelResumed": "已恢復更新追蹤",
"versionIgnored": "已忽略此版本的更新",
"versionUnignored": "已重新啟用此版本",
"versionDeleted": "已刪除此版本"
}
}
}
},
@@ -906,7 +1027,9 @@
"loraFailedToSend": "傳送 LoRA 到工作流失敗",
"recipeAdded": "配方已附加到工作流",
"recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗"
"recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noTargetNodeSelected": "未選擇目標節點"
},
"nodeSelector": {
"recipe": "配方",
@@ -951,6 +1074,11 @@
},
"update": {
"title": "檢查更新",
"notificationsTitle": "通知中心",
"tabs": {
"updates": "更新",
"messages": "訊息"
},
"updateAvailable": "有新版本可用",
"noChangelogAvailable": "無詳細更新日誌。請至 GitHub 查看更多資訊。",
"currentVersion": "目前版本",
@@ -982,6 +1110,13 @@
"nightly": {
"warning": "警告Nightly 版本可能包含實驗性功能且可能不穩定。",
"enable": "啟用 Nightly 更新"
},
"banners": {
"recent": "最新通知",
"empty": "目前沒有最近的橫幅通知。",
"shown": "{time} 顯示",
"dismissed": "{time} 關閉",
"active": "仍在顯示"
}
},
"support": {
@@ -1101,6 +1236,12 @@
"bulkContentRatingSet": "已將 {count} 個模型的內容分級設定為 {level}",
"bulkContentRatingPartial": "已將 {success} 個模型的內容分級設定為 {level}{failed} 個失敗",
"bulkContentRatingFailed": "無法更新所選模型的內容分級",
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
"bulkUpdatesNone": "所選 {type} 未找到更新",
"bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新",
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}",
"bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}",
"invalidCharactersRemoved": "已移除檔名中的無效字元",
"filenameCannotBeEmpty": "檔案名稱不可為空",
"renameFailed": "重新命名檔案失敗:{message}",
@@ -1210,6 +1351,8 @@
"pauseFailed": "暫停下載失敗:{error}",
"downloadResumed": "下載已恢復",
"resumeFailed": "恢復下載失敗:{error}",
"downloadStopped": "下載已取消",
"stopFailed": "取消下載失敗:{error}",
"deleted": "範例圖片已刪除",
"deleteFailed": "刪除範例圖片失敗",
"setPreviewFailed": "設定預覽圖片失敗"

View File

@@ -2,12 +2,12 @@ import os
import platform
from pathlib import Path
import folder_paths # type: ignore
from typing import Dict, Iterable, List, Mapping, Set
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set
import logging
import json
import urllib.parse
from .utils.settings_paths import ensure_settings_file
from .utils.settings_paths import ensure_settings_file, load_settings_template
# 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"
@@ -45,6 +45,30 @@ def _normalize_folder_paths_for_comparison(
return normalized
def _normalize_library_folder_paths(
library_payload: Mapping[str, Any]
) -> Dict[str, Set[str]]:
"""Return normalized folder paths extracted from a library payload."""
folder_paths = library_payload.get("folder_paths")
if isinstance(folder_paths, Mapping):
return _normalize_folder_paths_for_comparison(folder_paths)
return {}
def _get_template_folder_paths() -> Dict[str, Set[str]]:
"""Return normalized folder paths defined in the bundled template."""
template_payload = load_settings_template()
if not template_payload:
return {}
folder_paths = template_payload.get("folder_paths")
if isinstance(folder_paths, Mapping):
return _normalize_folder_paths_for_comparison(folder_paths)
return {}
class Config:
"""Global configuration for LoRA Manager"""
@@ -74,12 +98,50 @@ class Config:
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
try:
ensure_settings_file(logger)
from .services.settings_manager import settings as settings_service
from .services.settings_manager import get_settings_manager
settings_service = get_settings_manager()
libraries = settings_service.get_libraries()
comfy_library = libraries.get("comfyui", {})
default_library = libraries.get("default", {})
template_folder_paths = _get_template_folder_paths()
default_library_paths: Dict[str, Set[str]] = {}
if isinstance(default_library, Mapping):
default_library_paths = _normalize_library_folder_paths(default_library)
libraries_changed = False
if (
isinstance(default_library, Mapping)
and template_folder_paths
and default_library_paths == template_folder_paths
):
if "comfyui" in libraries:
try:
settings_service.delete_library("default")
libraries_changed = True
logger.info("Removed template 'default' library entry")
except Exception as delete_error:
logger.debug(
"Failed to delete template 'default' library: %s",
delete_error,
)
else:
try:
settings_service.rename_library("default", "comfyui")
libraries_changed = True
logger.info("Renamed template 'default' library to 'comfyui'")
except Exception as rename_error:
logger.debug(
"Failed to rename template 'default' library: %s",
rename_error,
)
if libraries_changed:
libraries = settings_service.get_libraries()
comfy_library = libraries.get("comfyui", {})
default_library = libraries.get("default", {})
target_folder_paths = {
'loras': list(self.loras_roots),
'checkpoints': list(self.checkpoints_roots or []),
@@ -89,9 +151,16 @@ class Config:
normalized_target_paths = _normalize_folder_paths_for_comparison(target_folder_paths)
if (not comfy_library and default_library and normalized_target_paths and
_normalize_folder_paths_for_comparison(default_library.get("folder_paths", {})) ==
normalized_target_paths):
normalized_default_paths: Optional[Dict[str, Set[str]]] = None
if isinstance(default_library, Mapping):
normalized_default_paths = _normalize_library_folder_paths(default_library)
if (
not comfy_library
and default_library
and normalized_target_paths
and normalized_default_paths == normalized_target_paths
):
try:
settings_service.rename_library("default", "comfyui")
logger.info("Renamed legacy 'default' library to 'comfyui'")
@@ -442,8 +511,9 @@ class Config:
"""Return the current library registry and active library name."""
try:
from .services.settings_manager import settings as settings_service
from .services.settings_manager import get_settings_manager
settings_service = get_settings_manager()
libraries = settings_service.get_libraries()
active_library = settings_service.get_active_library_name()
return {

View File

@@ -13,7 +13,7 @@ from .routes.misc_routes import MiscRoutes
from .routes.preview_routes import PreviewRoutes
from .routes.example_images_routes import ExampleImagesRoutes
from .services.service_registry import ServiceRegistry
from .services.settings_manager import settings
from .services.settings_manager import get_settings_manager
from .utils.example_images_migration import ExampleImagesMigration
from .services.websocket_manager import ws_manager
from .services.example_images_cleanup_service import ExampleImagesCleanupService
@@ -23,6 +23,37 @@ logger = logging.getLogger(__name__)
# Check if we're in standalone mode
STANDALONE_MODE = 'nodes' not in sys.modules
HEADER_SIZE_LIMIT = 16384
def _sanitize_size_limit(value):
"""Return a non-negative integer size for ``handler_args`` comparisons."""
try:
coerced = int(value)
except (TypeError, ValueError):
return 0
return coerced if coerced >= 0 else 0
class _SettingsProxy:
def __init__(self):
self._manager = None
def _resolve(self):
if self._manager is None:
self._manager = get_settings_manager()
return self._manager
def get(self, *args, **kwargs):
return self._resolve().get(*args, **kwargs)
def __getattr__(self, item):
return getattr(self._resolve(), item)
settings = _SettingsProxy()
class LoraManager:
"""Main entry point for LoRA Manager plugin"""
@@ -31,6 +62,24 @@ class LoraManager:
"""Initialize and register all routes using the new refactored architecture"""
app = PromptServer.instance.app
# 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
# limits. Cookies for unrelated apps are still sent to the plugin and
# may otherwise raise LineTooLong errors when the request parser reads
# them. Preserve any previously configured handler arguments while
# ensuring our minimum sizes are applied.
handler_args = getattr(app, "_handler_args", {}) or {}
updated_handler_args = dict(handler_args)
updated_handler_args["max_field_size"] = max(
_sanitize_size_limit(handler_args.get("max_field_size", 0)),
HEADER_SIZE_LIMIT,
)
updated_handler_args["max_line_size"] = max(
_sanitize_size_limit(handler_args.get("max_line_size", 0)),
HEADER_SIZE_LIMIT,
)
app._handler_args = updated_handler_args
# Configure aiohttp access logger to be less verbose
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)

View File

@@ -3,6 +3,18 @@ import os
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
def _store_checkpoint_metadata(metadata, node_id, model_name):
"""Store checkpoint model information when available."""
if not model_name:
return
metadata.setdefault(MODELS, {})
metadata[MODELS][node_id] = {
"name": model_name,
"type": "checkpoint",
"node_id": node_id
}
class NodeMetadataExtractor:
"""Base class for node-specific metadata extraction"""
@@ -29,12 +41,36 @@ class CheckpointLoaderExtractor(NodeMetadataExtractor):
return
model_name = inputs.get("ckpt_name")
if model_name:
metadata[MODELS][node_id] = {
"name": model_name,
"type": "checkpoint",
"node_id": node_id
}
_store_checkpoint_metadata(metadata, node_id, model_name)
class NunchakuFluxDiTLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "model_path" not in inputs:
return
model_name = inputs.get("model_path")
_store_checkpoint_metadata(metadata, node_id, model_name)
class NunchakuQwenImageDiTLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "model_name" not in inputs:
return
model_name = inputs.get("model_name")
_store_checkpoint_metadata(metadata, node_id, model_name)
class GGUFLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "gguf_name" not in inputs:
return
model_name = inputs.get("gguf_name")
_store_checkpoint_metadata(metadata, node_id, model_name)
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod
@@ -43,12 +79,7 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
return
model_name = inputs.get("ckpt_name")
if model_name:
metadata[MODELS][node_id] = {
"name": model_name,
"type": "checkpoint",
"node_id": node_id
}
_store_checkpoint_metadata(metadata, node_id, model_name)
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
active_loras = []
@@ -660,12 +691,17 @@ NODE_EXTRACTORS = {
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
"NunchakuQwenImageDiTLoader": NunchakuQwenImageDiTLoaderExtractor, # ComfyUI-Nunchaku
"LoaderGGUF": GGUFLoaderExtractor, # calcuis gguf
"LoaderGGUFAdvanced": GGUFLoaderExtractor, # calcuis gguf
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
"LoraLoader": LoraLoaderExtractor,
"LoraManagerLoader": LoraLoaderManagerExtractor,
# Conditioning
"CLIPTextEncode": CLIPTextEncodeExtractor,
"PromptLoraManager": CLIPTextEncodeExtractor,
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb

View File

@@ -1,7 +1,6 @@
import logging
import re
from nodes import LoraLoader
from comfy.comfy_types import IO # type: ignore
from ..utils.utils import get_lora_info
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
@@ -17,7 +16,7 @@ class LoraManagerLoader:
"required": {
"model": ("MODEL",),
# "clip": ("CLIP",),
"text": (IO.STRING, {
"text": ("STRING", {
"multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True,
@@ -28,7 +27,7 @@ class LoraManagerLoader:
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras"
@@ -141,8 +140,7 @@ class LoraManagerTextLoader:
return {
"required": {
"model": ("MODEL",),
"lora_syntax": (IO.STRING, {
"defaultInput": True,
"lora_syntax": ("STRING", {
"forceInput": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
}),
@@ -153,7 +151,7 @@ class LoraManagerTextLoader:
}
}
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras_from_text"

View File

@@ -1,4 +1,3 @@
from comfy.comfy_types import IO # type: ignore
import os
from ..utils.utils import get_lora_info
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
@@ -15,7 +14,7 @@ class LoraStacker:
def INPUT_TYPES(cls):
return {
"required": {
"text": (IO.STRING, {
"text": ("STRING", {
"multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True,
@@ -26,7 +25,7 @@ class LoraStacker:
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("LORA_STACK", IO.STRING, IO.STRING)
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
FUNCTION = "stack_loras"

59
py/nodes/prompt.py Normal file
View File

@@ -0,0 +1,59 @@
from typing import Any, Optional
class PromptLoraManager:
"""Encodes text (and optional trigger words) into CLIP conditioning."""
NAME = "Prompt (LoraManager)"
CATEGORY = "Lora Manager/conditioning"
DESCRIPTION = (
"Encodes a text prompt using a CLIP model into an embedding that can be used "
"to guide the diffusion model towards generating specific images."
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
'STRING',
{
"multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True,
"tooltip": "The text to be encoded.",
},
),
"clip": (
'CLIP',
{"tooltip": "The CLIP model used for encoding the text."},
),
},
"optional": {
"trigger_words": (
'STRING',
{
"forceInput": True,
"tooltip": (
"Optional trigger words to prepend to the text before "
"encoding."
)
},
)
},
}
RETURN_TYPES = ('CONDITIONING', 'STRING',)
RETURN_NAMES = ('CONDITIONING', 'PROMPT',)
OUTPUT_TOOLTIPS = (
"A conditioning containing the embedded text used to guide the diffusion model.",
)
FUNCTION = "encode"
def encode(self, text: str, clip: Any, trigger_words: Optional[str] = None):
prompt = text
if trigger_words:
prompt = ", ".join([trigger_words, text])
from nodes import CLIPTextEncode # type: ignore
conditioning = CLIPTextEncode().encode(clip, prompt)[0]
return (conditioning, prompt,)

View File

@@ -1,6 +1,5 @@
import json
import re
from server import PromptServer # type: ignore
from .utils import FlexibleOptionalInputType, any_type
import logging

View File

@@ -110,10 +110,14 @@ def nunchaku_load_lora(model, lora_name, lora_strength):
model_wrapper.model = transformer
ret_model_wrapper.model = transformer
# Get full path to the LoRA file
lora_path = folder_paths.get_full_path("loras", lora_name)
# 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)
if not lora_path or not os.path.isfile(lora_path):
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
return model
ret_model_wrapper.loras.append((lora_path, lora_strength))
# Convert the LoRA to diffusers format
sd = to_diffusers(lora_path)

View File

@@ -1,4 +1,3 @@
from comfy.comfy_types import IO # type: ignore
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
@@ -16,7 +15,7 @@ class WanVideoLoraSelect:
"required": {
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
"merge_loras": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
"text": (IO.STRING, {
"text": ("STRING", {
"multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True,
@@ -27,7 +26,7 @@ class WanVideoLoraSelect:
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_loras"

View File

@@ -1,5 +1,4 @@
from comfy.comfy_types import IO
import folder_paths
import folder_paths # type: ignore
from ..utils.utils import get_lora_info
from .utils import any_type
import logging
@@ -20,9 +19,8 @@ class WanVideoLoraSelectFromText:
"required": {
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
"merge_lora": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
"lora_syntax": (IO.STRING, {
"lora_syntax": ("STRING", {
"multiline": True,
"defaultInput": True,
"forceInput": True,
"tooltip": "Connect a TEXT output for LoRA syntax: <lora:name:strength>"
}),
@@ -34,7 +32,7 @@ class WanVideoLoraSelectFromText:
}
}
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_loras_from_syntax"

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Callable, Dict, Mapping
from typing import TYPE_CHECKING, Callable, Dict, Mapping
import jinja2
from aiohttp import web
@@ -17,7 +17,7 @@ from ..services.model_lifecycle_service import ModelLifecycleService
from ..services.preview_asset_service import PreviewAssetService
from ..services.server_i18n import server_i18n as default_server_i18n
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import settings as default_settings
from ..services.settings_manager import get_settings_manager
from ..services.tag_update_service import TagUpdateService
from ..services.websocket_manager import ws_manager as default_ws_manager
from ..services.use_cases import (
@@ -42,8 +42,12 @@ from .handlers.model_handlers import (
ModelMoveHandler,
ModelPageView,
ModelQueryHandler,
ModelUpdateHandler,
)
if TYPE_CHECKING:
from ..services.model_update_service import ModelUpdateService
logger = logging.getLogger(__name__)
@@ -56,14 +60,14 @@ class BaseModelRoutes(ABC):
self,
service=None,
*,
settings_service=default_settings,
settings_service=None,
ws_manager=default_ws_manager,
server_i18n=default_server_i18n,
metadata_provider_factory=get_default_metadata_provider,
) -> None:
self.service = None
self.model_type = ""
self._settings = settings_service
self._settings = settings_service or get_settings_manager()
self._ws_manager = ws_manager
self._server_i18n = server_i18n
self._metadata_provider_factory = metadata_provider_factory
@@ -90,7 +94,7 @@ class BaseModelRoutes(ABC):
self._metadata_sync_service = MetadataSyncService(
metadata_manager=MetadataManager,
preview_service=self._preview_service,
settings=settings_service,
settings=self._settings,
default_metadata_provider_factory=metadata_provider_factory,
metadata_provider_selector=get_metadata_provider,
)
@@ -99,10 +103,18 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager,
download_manager_factory=ServiceRegistry.get_download_manager,
)
self._model_update_service: ModelUpdateService | None = None
if service is not None:
self.attach_service(service)
def set_model_update_service(self, service: "ModelUpdateService") -> None:
"""Attach the model update tracking service."""
self._model_update_service = service
self._handler_set = None
self._handler_mapping = None
def attach_service(self, service) -> None:
"""Attach a model service and rebuild handler dependencies."""
self.service = service
@@ -127,6 +139,7 @@ class BaseModelRoutes(ABC):
def _create_handler_set(self) -> ModelHandlerSet:
service = self._ensure_service()
update_service = self._ensure_model_update_service()
page_view = ModelPageView(
template_env=self.template_env,
template_name=self.template_name or "",
@@ -186,6 +199,12 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager,
logger=logger,
)
updates = ModelUpdateHandler(
service=service,
update_service=update_service,
metadata_provider_selector=get_metadata_provider,
logger=logger,
)
return ModelHandlerSet(
page_view=page_view,
listing=listing,
@@ -195,6 +214,7 @@ class BaseModelRoutes(ABC):
civitai=civitai,
move=move,
auto_organize=auto_organize,
updates=updates,
)
@property
@@ -273,3 +293,8 @@ class BaseModelRoutes(ABC):
return proxy
def _ensure_model_update_service(self) -> "ModelUpdateService":
if self._model_update_service is None:
raise RuntimeError("Model update service has not been attached")
return self._model_update_service

View File

@@ -18,7 +18,7 @@ from ..services.recipes import (
)
from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils
from .handlers.recipe_handlers import (
@@ -48,7 +48,7 @@ class BaseRecipeRoutes:
self.recipe_scanner = None
self.lora_scanner = None
self.civitai_client = None
self.settings = settings
self.settings = get_settings_manager()
self.server_i18n = server_i18n
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),

View File

@@ -20,8 +20,10 @@ class CheckpointRoutes(BaseModelRoutes):
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
self.service = CheckpointService(checkpoint_scanner)
update_service = await ServiceRegistry.get_model_update_service()
self.service = CheckpointService(checkpoint_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)
@@ -93,4 +95,4 @@ class CheckpointRoutes(BaseModelRoutes):
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
}, status=500)

View File

@@ -19,8 +19,10 @@ class EmbeddingRoutes(BaseModelRoutes):
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
self.service = EmbeddingService(embedding_scanner)
update_service = await ServiceRegistry.get_model_update_service()
self.service = EmbeddingService(embedding_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)

View File

@@ -22,6 +22,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/example-images-status", "get_example_images_status"),
RouteDefinition("POST", "/api/lm/pause-example-images", "pause_example_images"),
RouteDefinition("POST", "/api/lm/resume-example-images", "resume_example_images"),
RouteDefinition("POST", "/api/lm/stop-example-images", "stop_example_images"),
RouteDefinition("POST", "/api/lm/open-example-images-folder", "open_example_images_folder"),
RouteDefinition("GET", "/api/lm/example-image-files", "get_example_image_files"),
RouteDefinition("GET", "/api/lm/has-example-images", "has_example_images"),

View File

@@ -68,6 +68,13 @@ class ExampleImagesDownloadHandler:
except DownloadNotRunningError as exc:
return web.json_response({'success': False, 'error': str(exc)}, status=400)
async def stop_example_images(self, request: web.Request) -> web.StreamResponse:
try:
result = await self._download_manager.stop_download(request)
return web.json_response(result)
except DownloadNotRunningError as exc:
return web.json_response({'success': False, 'error': str(exc)}, status=400)
async def force_download_example_images(self, request: web.Request) -> web.StreamResponse:
try:
payload = await request.json()
@@ -149,6 +156,7 @@ class ExampleImagesHandlerSet:
"get_example_images_status": self.download.get_example_images_status,
"pause_example_images": self.download.pause_example_images,
"resume_example_images": self.download.resume_example_images,
"stop_example_images": self.download.stop_example_images,
"force_download_example_images": self.download.force_download_example_images,
"import_example_images": self.management.import_example_images,
"delete_example_image": self.management.delete_example_image,

View File

@@ -24,10 +24,18 @@ from ...services.metadata_service import (
update_metadata_providers,
)
from ...services.service_registry import ServiceRegistry
from ...services.settings_manager import settings as default_settings
from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader
from ...utils.constants import DEFAULT_NODE_COLOR, NODE_TYPES, SUPPORTED_MEDIA_EXTENSIONS
from ...services.errors import ResourceNotFoundError
from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR,
NODE_TYPES,
SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES,
)
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import is_valid_example_images_root
from ...utils.lora_metadata import extract_trained_words
from ...utils.usage_stats import UsageStats
@@ -80,7 +88,7 @@ class NodeRegistry:
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._nodes: Dict[int, dict] = {}
self._nodes: Dict[str, dict] = {}
self._registry_updated = asyncio.Event()
async def register_nodes(self, nodes: list[dict]) -> None:
@@ -88,15 +96,53 @@ class NodeRegistry:
self._nodes.clear()
for node in nodes:
node_id = node["node_id"]
graph_id = str(node["graph_id"])
unique_id = f"{graph_id}:{node_id}"
node_type = node.get("type", "")
type_id = NODE_TYPES.get(node_type, 0)
bgcolor = node.get("bgcolor") or DEFAULT_NODE_COLOR
self._nodes[node_id] = {
raw_capabilities = node.get("capabilities")
capabilities: dict = {}
if isinstance(raw_capabilities, dict):
capabilities = dict(raw_capabilities)
raw_widget_names: list | None = node.get("widget_names")
if not isinstance(raw_widget_names, list):
capability_widget_names = capabilities.get("widget_names")
raw_widget_names = capability_widget_names if isinstance(capability_widget_names, list) else None
widget_names: list[str] = []
if isinstance(raw_widget_names, list):
widget_names = [
str(widget_name)
for widget_name in raw_widget_names
if isinstance(widget_name, str) and widget_name
]
if widget_names:
capabilities["widget_names"] = widget_names
else:
capabilities.pop("widget_names", None)
if "supports_lora" in capabilities:
capabilities["supports_lora"] = bool(capabilities["supports_lora"])
comfy_class = node.get("comfy_class")
if not isinstance(comfy_class, str) or not comfy_class:
comfy_class = node_type if isinstance(node_type, str) else None
self._nodes[unique_id] = {
"id": node_id,
"graph_id": graph_id,
"graph_name": node.get("graph_name"),
"unique_id": unique_id,
"bgcolor": bgcolor,
"title": node.get("title"),
"type": type_id,
"type_name": node_type,
"comfy_class": comfy_class,
"capabilities": capabilities,
"widget_names": widget_names,
}
logger.debug("Registered %s nodes in registry", len(nodes))
self._registry_updated.set()
@@ -147,9 +193,13 @@ class SettingsHandler:
"autoplay_on_hover",
"display_density",
"card_info_display",
"show_folder_sidebar",
"include_trigger_words",
"show_only_sfw",
"compact_mode",
"priority_tags",
"model_card_footer_action",
"model_name_display",
)
_PROXY_KEYS = {"proxy_enabled", "proxy_host", "proxy_port", "proxy_username", "proxy_password", "proxy_type"}
@@ -157,11 +207,11 @@ class SettingsHandler:
def __init__(
self,
*,
settings_service=default_settings,
settings_service=None,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
downloader_factory: Callable[[], Awaitable[DownloaderProtocol]] = get_downloader,
) -> None:
self._settings = settings_service
self._settings = settings_service or get_settings_manager()
self._metadata_provider_updater = metadata_provider_updater
self._downloader_factory = downloader_factory
@@ -190,11 +240,28 @@ class SettingsHandler:
value = self._settings.get(key)
if value is not None:
response_data[key] = value
return web.json_response({"success": True, "settings": response_data})
settings_file = getattr(self._settings, "settings_file", None)
if settings_file:
response_data["settings_file"] = settings_file
messages_getter = getattr(self._settings, "get_startup_messages", None)
messages = list(messages_getter()) if callable(messages_getter) else []
return web.json_response({
"success": True,
"settings": response_data,
"messages": messages,
})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting settings: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_priority_tags(self, request: web.Request) -> web.Response:
try:
suggestions = self._settings.get_priority_tag_suggestions()
return web.json_response({"success": True, "tags": suggestions})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting priority tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def activate_library(self, request: web.Request) -> web.Response:
"""Activate the selected library."""
@@ -330,16 +397,65 @@ class LoraCodeHandler:
logger.error("Error broadcasting lora code: %s", exc)
results.append({"node_id": "broadcast", "success": False, "error": str(exc)})
else:
for node_id in node_ids:
for entry in node_ids:
node_identifier = entry
graph_identifier = None
if isinstance(entry, dict):
node_identifier = entry.get("node_id")
graph_identifier = entry.get("graph_id")
if node_identifier is None:
results.append(
{
"node_id": node_identifier,
"graph_id": graph_identifier,
"success": False,
"error": "Missing node_id parameter",
}
)
continue
try:
parsed_node_id = int(node_identifier)
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload = {
"id": parsed_node_id,
"lora_code": lora_code,
"mode": mode,
}
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
try:
self._prompt_server.instance.send_sync(
"lora_code_update",
{"id": node_id, "lora_code": lora_code, "mode": mode},
payload,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": True,
}
)
results.append({"node_id": node_id, "success": True})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error sending lora code to node %s: %s", node_id, exc)
results.append({"node_id": node_id, "success": False, "error": str(exc)})
logger.error(
"Error sending lora code to node %s (graph %s): %s",
parsed_node_id,
graph_identifier,
exc,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": False,
"error": str(exc),
}
)
return web.json_response({"success": True, "results": results})
except Exception as exc: # pragma: no cover - defensive logging
@@ -504,7 +620,10 @@ class ModelLibraryHandler:
if not metadata_provider:
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503)
response = await metadata_provider.get_model_versions(model_id)
try:
response = await metadata_provider.get_model_versions(model_id)
except ResourceNotFoundError:
return web.json_response({"success": False, "error": "Model not found"}, status=404)
if not response or not response.get("modelVersions"):
return web.json_response({"success": False, "error": "Model not found"}, status=404)
@@ -557,17 +676,118 @@ class ModelLibraryHandler:
logger.error("Failed to get model versions status: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_civitai_user_models(self, request: web.Request) -> web.Response:
try:
username = request.query.get("username")
if not username:
return web.json_response({"success": False, "error": "Missing required parameter: username"}, status=400)
metadata_provider = await self._metadata_provider_factory()
if not metadata_provider:
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503)
try:
models = await metadata_provider.get_user_models(username)
except NotImplementedError:
return web.json_response({"success": False, "error": "Metadata provider does not support user model queries"}, status=501)
if models is None:
return web.json_response({"success": False, "error": "Failed to fetch user models"}, status=502)
if not isinstance(models, list):
models = []
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
normalized_allowed_types = {model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES}
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
type_scanner_map: Dict[str, object | None] = {
**{alias: lora_scanner for alias in lora_type_aliases},
"checkpoint": checkpoint_scanner,
"textualinversion": embedding_scanner,
}
versions: list[dict] = []
for model in models:
if not isinstance(model, dict):
continue
model_type = str(model.get("type", "")).lower()
if model_type not in normalized_allowed_types:
continue
scanner = type_scanner_map.get(model_type)
if scanner is None:
return web.json_response({"success": False, "error": f'Scanner for type "{model_type}" is not available'}, status=503)
tags_value = model.get("tags")
tags = tags_value if isinstance(tags_value, list) else []
model_id = model.get("id")
try:
model_id_int = int(model_id)
except (TypeError, ValueError):
continue
model_name = model.get("name", "")
versions_data = model.get("modelVersions")
if not isinstance(versions_data, list):
continue
for version in versions_data:
if not isinstance(version, dict):
continue
version_id = version.get("id")
try:
version_id_int = int(version_id)
except (TypeError, ValueError):
continue
images = version.get("images") or []
thumbnail_url = None
if images and isinstance(images, list):
first_image = images[0]
if isinstance(first_image, dict):
raw_url = first_image.get("url")
media_type = first_image.get("type")
rewritten_url, _ = rewrite_preview_url(raw_url, media_type)
thumbnail_url = rewritten_url
in_library = await scanner.check_model_version_exists(version_id_int)
versions.append(
{
"modelId": model_id_int,
"versionId": version_id_int,
"modelName": model_name,
"versionName": version.get("name", ""),
"type": model.get("type"),
"tags": tags,
"baseModel": version.get("baseModel"),
"thumbnailUrl": thumbnail_url,
"inLibrary": in_library,
}
)
return web.json_response({"success": True, "username": username, "versions": versions})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get Civitai user models: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class MetadataArchiveHandler:
def __init__(
self,
*,
metadata_archive_manager_factory: Callable[[], Awaitable[MetadataArchiveManagerProtocol]] = get_metadata_archive_manager,
settings_service=default_settings,
settings_service=None,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
) -> None:
self._metadata_archive_manager_factory = metadata_archive_manager_factory
self._settings = settings_service
self._settings = settings_service or get_settings_manager()
self._metadata_provider_updater = metadata_provider_updater
async def download_metadata_archive(self, request: web.Request) -> web.Response:
@@ -679,10 +899,21 @@ class NodeRegistryHandler:
node_id = node.get("node_id")
if node_id is None:
return web.json_response({"success": False, "error": f"Node {index} missing node_id parameter"}, status=400)
graph_id = node.get("graph_id")
if graph_id is None:
return web.json_response({"success": False, "error": f"Node {index} missing graph_id parameter"}, status=400)
graph_name = node.get("graph_name")
try:
node["node_id"] = int(node_id)
except (TypeError, ValueError):
return web.json_response({"success": False, "error": f"Node {index} node_id must be an integer"}, status=400)
node["graph_id"] = str(graph_id)
if graph_name is None:
node["graph_name"] = None
elif isinstance(graph_name, str):
node["graph_name"] = graph_name
else:
node["graph_name"] = str(graph_name)
await self._node_registry.register_nodes(nodes)
return web.json_response({"success": True, "message": f"{len(nodes)} nodes registered successfully"})
@@ -735,6 +966,88 @@ class NodeRegistryHandler:
logger.error("Failed to get registry: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": "Internal Error", "message": str(exc)}, status=500)
async def update_node_widget(self, request: web.Request) -> web.Response:
try:
data = await request.json()
widget_name = data.get("widget_name")
value = data.get("value")
node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name:
return web.json_response({"success": False, "error": "Missing widget_name parameter"}, status=400)
if not isinstance(value, str) or not value:
return web.json_response({"success": False, "error": "Missing value parameter"}, status=400)
if not isinstance(node_ids, list) or not node_ids:
return web.json_response(
{"success": False, "error": "node_ids must be a non-empty list"},
status=400,
)
results = []
for entry in node_ids:
node_identifier = entry
graph_identifier = None
if isinstance(entry, dict):
node_identifier = entry.get("node_id")
graph_identifier = entry.get("graph_id")
if node_identifier is None:
results.append(
{
"node_id": node_identifier,
"graph_id": graph_identifier,
"success": False,
"error": "Missing node_id parameter",
}
)
continue
try:
parsed_node_id = int(node_identifier)
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload = {
"id": parsed_node_id,
"widget_name": widget_name,
"value": value,
}
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
try:
self._prompt_server.instance.send_sync("lm_widget_update", payload)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": True,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error sending widget update to node %s (graph %s): %s",
parsed_node_id,
graph_identifier,
exc,
)
results.append(
{
"node_id": parsed_node_id,
"graph_id": payload.get("graph_id"),
"success": False,
"error": str(exc),
}
)
return web.json_response({"success": True, "results": results})
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to update node widget: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class MiscHandlerSet:
"""Aggregate handlers into a lookup compatible with the registrar."""
@@ -769,6 +1082,7 @@ class MiscHandlerSet:
"health_check": self.health.health_check,
"get_settings": self.settings.get_settings,
"update_settings": self.settings.update_settings,
"get_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries,
"activate_library": self.settings.activate_library,
"update_usage_stats": self.usage_stats.update_usage_stats,
@@ -777,8 +1091,10 @@ class MiscHandlerSet:
"get_trained_words": self.trained_words.get_trained_words,
"get_model_example_files": self.model_examples.get_model_example_files,
"register_nodes": self.node_registry.register_nodes,
"update_node_widget": self.node_registry.update_node_widget,
"get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists,
"get_civitai_user_models": self.model_library.get_civitai_user_models,
"download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive,
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,

View File

@@ -6,7 +6,7 @@ import json
import logging
import os
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Iterable, Mapping, Optional
from typing import Awaitable, Callable, Dict, Iterable, List, Mapping, Optional
from aiohttp import web
import jinja2
@@ -29,7 +29,9 @@ from ...services.use_cases import (
)
from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback
from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.file_utils import calculate_sha256
from ...utils.metadata_manager import MetadataManager
class ModelPageView:
@@ -164,6 +166,8 @@ class ModelListingHandler:
except (json.JSONDecodeError, TypeError):
pass
update_available_only = request.query.get("update_available_only", "false").lower() == "true"
return {
"page": page,
"page_size": page_size,
@@ -176,6 +180,7 @@ class ModelListingHandler:
"search_options": search_options,
"hash_filters": hash_filters,
"favorites_only": favorites_only,
"update_available_only": update_available_only,
**self._parse_specific_params(request),
}
@@ -244,6 +249,8 @@ class ModelManagementHandler:
if not model_data.get("sha256"):
return web.json_response({"success": False, "error": "No SHA256 hash found"}, status=400)
await MetadataManager.hydrate_model_data(model_data)
success, error = await self._metadata_sync.fetch_and_update_model(
sha256=model_data["sha256"],
file_path=file_path,
@@ -755,6 +762,30 @@ class ModelDownloadHandler:
self._logger.error("Error cancelling download via GET: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def pause_download_get(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response({"success": False, "error": "Download ID is required"}, status=400)
result = await self._download_coordinator.pause_download(download_id)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error pausing download via GET: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def resume_download_get(self, request: web.Request) -> web.Response:
try:
download_id = request.query.get("download_id")
if not download_id:
return web.json_response({"success": False, "error": "Download ID is required"}, status=400)
result = await self._download_coordinator.resume_download(download_id)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
except Exception as exc:
self._logger.error("Error resuming download via GET: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_download_progress(self, request: web.Request) -> web.Response:
try:
download_id = request.match_info.get("download_id")
@@ -763,7 +794,23 @@ class ModelDownloadHandler:
progress_data = self._ws_manager.get_download_progress(download_id)
if progress_data is None:
return web.json_response({"success": False, "error": "Download ID not found"}, status=404)
return web.json_response({"success": True, "progress": progress_data.get("progress", 0)})
response_payload = {
"success": True,
"progress": progress_data.get("progress", 0),
"bytes_downloaded": progress_data.get("bytes_downloaded"),
"total_bytes": progress_data.get("total_bytes"),
"bytes_per_second": progress_data.get("bytes_per_second", 0.0),
}
status = progress_data.get("status")
if status and status != "progress":
response_payload["status"] = status
if "message" in progress_data:
response_payload["message"] = progress_data["message"]
elif status is None and "message" in progress_data:
response_payload["message"] = progress_data["message"]
return web.json_response(response_payload)
except Exception as exc:
self._logger.error("Error getting download progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -813,7 +860,10 @@ class ModelCivitaiHandler:
try:
model_id = request.match_info["model_id"]
metadata_provider = await self._metadata_provider_factory()
response = await metadata_provider.get_model_versions(model_id)
try:
response = await metadata_provider.get_model_versions(model_id)
except ResourceNotFoundError:
return web.Response(status=404, text="Model not found")
if not response or not response.get("modelVersions"):
return web.Response(status=404, text="Model not found")
@@ -825,18 +875,30 @@ class ModelCivitaiHandler:
status=400,
)
cache = await self._service.scanner.get_cached_data()
version_index = cache.version_index
for version in versions:
model_file = self._find_model_file(version.get("files", [])) if isinstance(version.get("files"), Iterable) else None
if model_file:
hashes = model_file.get("hashes", {}) if isinstance(model_file, Mapping) else {}
sha256 = hashes.get("SHA256") if isinstance(hashes, Mapping) else None
if sha256:
version["existsLocally"] = self._service.has_hash(sha256)
if version["existsLocally"]:
version["localPath"] = self._service.get_path_by_hash(sha256)
version["modelSizeKB"] = model_file.get("sizeKB") if isinstance(model_file, Mapping) else None
version_id = None
version_id_raw = version.get("id")
if version_id_raw is not None:
try:
version_id = int(str(version_id_raw))
except (TypeError, ValueError):
version_id = None
cache_entry = version_index.get(version_id) if (version_id is not None and version_index) else None
version["existsLocally"] = cache_entry is not None
if cache_entry and isinstance(cache_entry, Mapping):
local_path = cache_entry.get("file_path")
if local_path:
version["localPath"] = local_path
else:
version["existsLocally"] = False
version.pop("localPath", None)
model_file = self._find_model_file(version.get("files", [])) if isinstance(version.get("files"), Iterable) else None
if model_file and isinstance(model_file, Mapping):
version["modelSizeKB"] = model_file.get("sizeKB")
return web.json_response(versions)
except Exception as exc:
self._logger.error("Error fetching %s model versions: %s", self._service.model_type, exc)
@@ -962,6 +1024,282 @@ class ModelAutoOrganizeHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelUpdateHandler:
"""Handle update tracking requests."""
def __init__(
self,
*,
service,
update_service,
metadata_provider_selector,
logger: logging.Logger,
) -> None:
self._service = service
self._update_service = update_service
self._metadata_provider_selector = metadata_provider_selector
self._logger = logger
async def refresh_model_updates(self, request: web.Request) -> web.Response:
payload = await self._read_json(request)
force_refresh = self._parse_bool(request.query.get("force")) or self._parse_bool(
payload.get("force")
)
raw_model_ids = payload.get("modelIds")
if raw_model_ids is None:
raw_model_ids = payload.get("model_ids")
target_model_ids: list[int] = []
if isinstance(raw_model_ids, (list, tuple, set)):
for value in raw_model_ids:
normalized = self._normalize_model_id(value)
if normalized is not None:
target_model_ids.append(normalized)
if target_model_ids:
target_model_ids = sorted(set(target_model_ids))
provider = await self._get_civitai_provider()
if provider is None:
return web.json_response(
{"success": False, "error": "Civitai provider not available"}, status=503
)
try:
records = await self._update_service.refresh_for_model_type(
self._service.model_type,
self._service.scanner,
provider,
force_refresh=force_refresh,
target_model_ids=target_model_ids or None,
)
except RateLimitError as exc:
return web.json_response(
{"success": False, "error": str(exc) or "Rate limited"}, status=429
)
except Exception as exc: # pragma: no cover - defensive logging
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
serialized_records = []
for record in records.values():
has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn():
serialized_records.append(self._serialize_record(record))
return web.json_response(
{
"success": True,
"records": serialized_records,
}
)
async def set_model_update_ignore(self, request: web.Request) -> web.Response:
payload = await self._read_json(request)
model_id = self._normalize_model_id(payload.get("modelId"))
if model_id is None:
return web.json_response({"success": False, "error": "modelId is required"}, status=400)
should_ignore = self._parse_bool(payload.get("shouldIgnore"))
record = await self._update_service.set_should_ignore(
self._service.model_type, model_id, should_ignore
)
return web.json_response({"success": True, "record": self._serialize_record(record)})
async def set_version_update_ignore(self, request: web.Request) -> web.Response:
payload = await self._read_json(request)
model_id = self._normalize_model_id(payload.get("modelId"))
version_id = self._normalize_model_id(payload.get("versionId"))
if model_id is None or version_id is None:
return web.json_response(
{"success": False, "error": "modelId and versionId are required"},
status=400,
)
should_ignore = self._parse_bool(payload.get("shouldIgnore"))
record = await self._update_service.set_version_should_ignore(
self._service.model_type,
model_id,
version_id,
should_ignore,
)
overrides = await self._build_version_context(record)
return web.json_response(
{"success": True, "record": self._serialize_record(record, version_context=overrides)}
)
async def get_model_update_status(self, request: web.Request) -> web.Response:
model_id = self._normalize_model_id(request.match_info.get("model_id"))
if model_id is None:
return web.json_response({"success": False, "error": "model_id must be an integer"}, status=400)
refresh = self._parse_bool(request.query.get("refresh"))
force = self._parse_bool(request.query.get("force"))
try:
record = await self._get_or_refresh_record(model_id, refresh=refresh, force=force)
except RateLimitError as exc:
return web.json_response(
{"success": False, "error": str(exc) or "Rate limited"}, status=429
)
if record is None:
return web.json_response(
{"success": False, "error": "Model not tracked"}, status=404
)
return web.json_response({"success": True, "record": self._serialize_record(record)})
async def get_model_versions(self, request: web.Request) -> web.Response:
model_id = self._normalize_model_id(request.match_info.get("model_id"))
if model_id is None:
return web.json_response(
{"success": False, "error": "model_id must be an integer"}, status=400
)
refresh = self._parse_bool(request.query.get("refresh"))
force = self._parse_bool(request.query.get("force"))
try:
record = await self._get_or_refresh_record(model_id, refresh=refresh, force=force)
except RateLimitError as exc:
return web.json_response(
{"success": False, "error": str(exc) or "Rate limited"}, status=429
)
if record is None:
return web.json_response(
{"success": False, "error": "Model not tracked"}, status=404
)
overrides = await self._build_version_context(record)
return web.json_response(
{"success": True, "record": self._serialize_record(record, version_context=overrides)}
)
async def _get_or_refresh_record(
self, model_id: int, *, refresh: bool, force: bool
) -> Optional[object]:
record = await self._update_service.get_record(self._service.model_type, model_id)
if record and not refresh and not force:
return record
provider = await self._get_civitai_provider()
if provider is None:
return record
return await self._update_service.refresh_single_model(
self._service.model_type,
model_id,
self._service.scanner,
provider,
force_refresh=force or refresh,
)
async def _get_civitai_provider(self):
try:
return await self._metadata_provider_selector("civitai_api")
except Exception as exc: # pragma: no cover - defensive log
self._logger.error("Failed to acquire civitai provider: %s", exc, exc_info=True)
return None
async def _read_json(self, request: web.Request) -> Dict:
if not request.can_read_body:
return {}
try:
return await request.json()
except Exception:
return {}
@staticmethod
def _parse_bool(value) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in {"1", "true", "yes"}
if isinstance(value, (int, float)):
return bool(value)
return False
@staticmethod
def _normalize_model_id(value) -> Optional[int]:
try:
if value is None:
return None
return int(value)
except (TypeError, ValueError):
return None
def _serialize_record(
self,
record,
*,
version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None,
) -> Dict:
context = version_context or {}
return {
"modelType": record.model_type,
"modelId": record.model_id,
"largestVersionId": record.largest_version_id,
"versionIds": record.version_ids,
"inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model,
"hasUpdate": record.has_update(),
"versions": [
self._serialize_version(version, context.get(version.version_id))
for version in record.versions
],
}
@staticmethod
def _serialize_version(version, context: Optional[Dict[str, Optional[str]]]) -> Dict:
context = context or {}
preview_override = context.get("preview_override")
preview_url = preview_override if preview_override is not None else version.preview_url
return {
"versionId": version.version_id,
"name": version.name,
"baseModel": version.base_model,
"releasedAt": version.released_at,
"sizeBytes": version.size_bytes,
"previewUrl": preview_url,
"isInLibrary": version.is_in_library,
"shouldIgnore": version.should_ignore,
"filePath": context.get("file_path"),
"fileName": context.get("file_name"),
}
async def _build_version_context(self, record) -> Dict[int, Dict[str, Optional[str]]]:
context: Dict[int, Dict[str, Optional[str]]] = {}
try:
cache = await self._service.scanner.get_cached_data()
except Exception as exc: # pragma: no cover - defensive logging
self._logger.debug("Failed to load cache while building preview overrides: %s", exc)
return context
version_index = getattr(cache, "version_index", None)
if not version_index:
return context
for version in record.versions:
if not version.is_in_library:
continue
cache_entry = version_index.get(version.version_id)
if isinstance(cache_entry, Mapping):
preview = cache_entry.get("preview_url")
context_entry: Dict[str, Optional[str]] = {
"file_path": cache_entry.get("file_path"),
"file_name": cache_entry.get("file_name"),
"preview_override": None,
}
if isinstance(preview, str) and preview:
context_entry["preview_override"] = config.get_preview_static_url(preview)
context[version.version_id] = context_entry
return context
@dataclass
class ModelHandlerSet:
"""Aggregate concrete handlers into a flat mapping."""
@@ -974,6 +1312,7 @@ class ModelHandlerSet:
civitai: ModelCivitaiHandler
move: ModelMoveHandler
auto_organize: ModelAutoOrganizeHandler
updates: ModelUpdateHandler
def to_route_mapping(self) -> Dict[str, Callable[[web.Request], Awaitable[web.Response]]]:
return {
@@ -1002,6 +1341,8 @@ class ModelHandlerSet:
"download_model": self.download.download_model,
"download_model_get": self.download.download_model_get,
"cancel_download_get": self.download.cancel_download_get,
"pause_download_get": self.download.pause_download_get,
"resume_download_get": self.download.resume_download_get,
"get_download_progress": self.download.get_download_progress,
"get_civitai_versions": self.civitai.get_civitai_versions,
"get_civitai_model_by_version": self.civitai.get_civitai_model_by_version,
@@ -1016,5 +1357,9 @@ class ModelHandlerSet:
"get_model_metadata": self.query.get_model_metadata,
"get_model_description": self.query.get_model_description,
"get_relative_paths": self.query.get_relative_paths,
"refresh_model_updates": self.updates.refresh_model_updates,
"set_model_update_ignore": self.updates.set_model_update_ignore,
"set_version_update_ignore": self.updates.set_version_update_ignore,
"get_model_update_status": self.updates.get_model_update_status,
"get_model_versions": self.updates.get_model_versions,
}

View File

@@ -23,8 +23,10 @@ class LoraRoutes(BaseModelRoutes):
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
lora_scanner = await ServiceRegistry.get_lora_scanner()
self.service = LoraService(lora_scanner)
update_service = await ServiceRegistry.get_model_update_service()
self.service = LoraService(lora_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)
@@ -229,11 +231,27 @@ class LoraRoutes(BaseModelRoutes):
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Send update to all connected trigger word toggle nodes
for node_id in node_ids:
PromptServer.instance.send_sync("trigger_word_update", {
"id": node_id,
for entry in node_ids:
node_identifier = entry
graph_identifier = None
if isinstance(entry, dict):
node_identifier = entry.get("node_id")
graph_identifier = entry.get("graph_id")
try:
parsed_node_id = int(node_identifier)
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload = {
"id": parsed_node_id,
"message": trigger_words_text
})
}
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
PromptServer.instance.send_sync("trigger_word_update", payload)
return web.json_response({"success": True})

View File

@@ -22,6 +22,7 @@ class RouteDefinition:
MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings", "get_settings"),
RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
@@ -32,8 +33,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/trained-words", "get_trained_words"),
RouteDefinition("GET", "/api/lm/model-example-files", "get_model_example_files"),
RouteDefinition("POST", "/api/lm/register-nodes", "register_nodes"),
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/civitai/user-models", "get_civitai_user_models"),
RouteDefinition("POST", "/api/lm/download-metadata-archive", "download_metadata_archive"),
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),

View File

@@ -14,7 +14,7 @@ from ..services.metadata_service import (
get_metadata_provider,
update_metadata_providers,
)
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
from ..services.downloader import get_downloader
from ..utils.usage_stats import UsageStats
from .handlers.misc_handlers import (
@@ -47,7 +47,7 @@ class MiscRoutes:
def __init__(
self,
*,
settings_service=settings,
settings_service=None,
usage_stats_factory: Callable[[], UsageStats] = UsageStats,
prompt_server: type[PromptServer] = PromptServer,
service_registry_adapter=build_service_registry_adapter(),
@@ -60,7 +60,7 @@ class MiscRoutes:
node_registry: NodeRegistry | None = None,
standalone_mode_flag: bool = standalone_mode,
) -> None:
self._settings = settings_service
self._settings = settings_service or get_settings_manager()
self._usage_stats_factory = usage_stats_factory
self._prompt_server = prompt_server
self._service_registry_adapter = service_registry_adapter

View File

@@ -55,9 +55,16 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"),
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/version/{modelVersionId}", "get_civitai_model_by_version"),
RouteDefinition("GET", "/api/lm/{prefix}/civitai/model/hash/{hash}", "get_civitai_model_by_hash"),
RouteDefinition("POST", "/api/lm/{prefix}/updates/refresh", "refresh_model_updates"),
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("GET", "/api/lm/download-model-get", "download_model_get"),
RouteDefinition("GET", "/api/lm/cancel-download-get", "cancel_download_get"),
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"),
)

View File

@@ -8,13 +8,32 @@ from collections import defaultdict, Counter
from typing import Dict, List, Any
from ..config import config
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
from ..services.server_i18n import server_i18n
from ..services.service_registry import ServiceRegistry
from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__)
class _SettingsProxy:
def __init__(self):
self._manager = None
def _resolve(self):
if self._manager is None:
self._manager = get_settings_manager()
return self._manager
def get(self, *args, **kwargs):
return self._resolve().get(*args, **kwargs)
def __getattr__(self, item):
return getattr(self._resolve(), item)
settings = _SettingsProxy()
class StatsRoutes:
"""Route handlers for Statistics page and API endpoints"""
@@ -66,7 +85,9 @@ class StatsRoutes:
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
# 获取用户语言设置
user_language = settings.get('language', 'en')
settings_object = settings
user_language = settings_object.get('language', 'en')
settings_manager = settings_object if not isinstance(settings_object, _SettingsProxy) else settings_object._resolve()
# 设置服务端i18n语言
server_i18n.set_locale(user_language)
@@ -79,7 +100,7 @@ class StatsRoutes:
template = self.template_env.get_template('statistics.html')
rendered = template.render(
is_initializing=is_initializing,
settings=settings,
settings=settings_manager,
request=request,
t=server_i18n.get_translation,
)

View File

@@ -205,8 +205,8 @@ class UpdateRoutes:
zip_path = tmp_zip_path
# Skip both settings.json and civitai folder
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai'])
# Skip both settings.json, civitai and model cache folder
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache'])
# Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir:

View File

@@ -1,15 +1,19 @@
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Type
import asyncio
from typing import Dict, List, Optional, Type, TYPE_CHECKING
import logging
import os
from ..utils.models import BaseModelMetadata
from ..utils.metadata_manager import MetadataManager
from .model_query import FilterCriteria, ModelCacheRepository, ModelFilterSet, SearchStrategy, SettingsProvider
from .settings_manager import settings as default_settings
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from .model_update_service import ModelUpdateService
class BaseModelService(ABC):
"""Base service class for all model types"""
@@ -23,6 +27,7 @@ class BaseModelService(ABC):
filter_set: Optional[ModelFilterSet] = None,
search_strategy: Optional[SearchStrategy] = None,
settings_provider: Optional[SettingsProvider] = None,
update_service: Optional["ModelUpdateService"] = None,
):
"""Initialize the service.
@@ -34,14 +39,16 @@ class BaseModelService(ABC):
filter_set: Filter component controlling folder/tag/favorites logic.
search_strategy: Search component for fuzzy/text matching.
settings_provider: Settings object; defaults to the global settings manager.
update_service: Service used to determine whether models have remote updates available.
"""
self.model_type = model_type
self.scanner = scanner
self.metadata_class = metadata_class
self.settings = settings_provider or default_settings
self.settings = settings_provider or get_settings_manager()
self.cache_repository = cache_repository or ModelCacheRepository(scanner)
self.filter_set = filter_set or ModelFilterSet(self.settings)
self.search_strategy = search_strategy or SearchStrategy()
self.update_service = update_service
async def get_paginated_data(
self,
@@ -56,36 +63,54 @@ class BaseModelService(ABC):
search_options: dict = None,
hash_filters: dict = None,
favorites_only: bool = False,
update_available_only: bool = False,
**kwargs,
) -> Dict:
"""Get paginated and filtered model data"""
sort_params = self.cache_repository.parse_sort(sort_by)
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
if hash_filters:
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
return self._paginate(filtered_data, page, page_size)
filtered_data = await self._apply_common_filters(
sorted_data,
folder=folder,
base_models=base_models,
tags=tags,
favorites_only=favorites_only,
search_options=search_options,
)
if search:
filtered_data = await self._apply_search_filters(
filtered_data,
search,
fuzzy_search,
search_options,
else:
filtered_data = await self._apply_common_filters(
sorted_data,
folder=folder,
base_models=base_models,
tags=tags,
favorites_only=favorites_only,
search_options=search_options,
)
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
if search:
filtered_data = await self._apply_search_filters(
filtered_data,
search,
fuzzy_search,
search_options,
)
return self._paginate(filtered_data, page, page_size)
filtered_data = await self._apply_specific_filters(filtered_data, **kwargs)
annotated_for_filter: Optional[List[Dict]] = None
if update_available_only:
annotated_for_filter = await self._annotate_update_flags(filtered_data)
filtered_data = [
item for item in annotated_for_filter
if item.get('update_available')
]
paginated = self._paginate(filtered_data, page, page_size)
if update_available_only:
# Items already include update flags thanks to the pre-filter annotation.
paginated['items'] = list(paginated['items'])
else:
paginated['items'] = await self._annotate_update_flags(
paginated['items'],
)
return paginated
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
@@ -144,6 +169,92 @@ class BaseModelService(ABC):
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
"""Apply model-specific filters - to be overridden by subclasses if needed"""
return data
async def _annotate_update_flags(
self,
items: List[Dict],
) -> List[Dict]:
"""Attach an update_available flag to each response item.
Items without a civitai model id default to False.
"""
if not items:
return []
annotated = [dict(item) for item in items]
if self.update_service is None:
for item in annotated:
item['update_available'] = False
return annotated
id_to_items: Dict[int, List[Dict]] = {}
ordered_ids: List[int] = []
for item in annotated:
model_id = self._extract_model_id(item)
if model_id is None:
item['update_available'] = False
continue
if model_id not in id_to_items:
id_to_items[model_id] = []
ordered_ids.append(model_id)
id_to_items[model_id].append(item)
if not ordered_ids:
return annotated
resolved: Optional[Dict[int, bool]] = None
bulk_method = getattr(self.update_service, "has_updates_bulk", None)
if callable(bulk_method):
try:
resolved = await bulk_method(self.model_type, ordered_ids)
except Exception as exc:
logger.error(
"Failed to resolve update status in bulk for %s models (%s): %s",
self.model_type,
ordered_ids,
exc,
exc_info=True,
)
resolved = None
if resolved is None:
tasks = [
self.update_service.has_update(self.model_type, model_id)
for model_id in ordered_ids
]
results = await asyncio.gather(*tasks, return_exceptions=True)
resolved = {}
for model_id, result in zip(ordered_ids, results):
if isinstance(result, Exception):
logger.error(
"Failed to resolve update status for model %s (%s): %s",
model_id,
self.model_type,
result,
)
continue
resolved[model_id] = bool(result)
for model_id, items_for_id in id_to_items.items():
flag = bool(resolved.get(model_id, False))
for item in items_for_id:
item['update_available'] = flag
return annotated
@staticmethod
def _extract_model_id(item: Dict) -> Optional[int]:
civitai = item.get('civitai') if isinstance(item, dict) else None
if not isinstance(civitai, dict):
return None
try:
value = civitai.get('modelId')
if value is None:
return None
return int(value)
except (TypeError, ValueError):
return None
def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict:
"""Apply pagination to filtered data"""
@@ -373,4 +484,4 @@ class BaseModelService(ABC):
x.lower() # Then alphabetically
))
return matching_paths[:limit]
return matching_paths[:limit]

View File

@@ -1,5 +1,5 @@
import logging
from typing import List
from typing import Any, Dict, List, Optional
from ..utils.models import CheckpointMetadata
from ..config import config
@@ -21,14 +21,33 @@ class CheckpointScanner(ModelScanner):
hash_index=ModelHashIndex()
)
def _resolve_model_type(self, root_path: Optional[str]) -> Optional[str]:
if not root_path:
return None
if config.checkpoints_roots and root_path in config.checkpoints_roots:
return "checkpoint"
if config.unet_roots and root_path in config.unet_roots:
return "diffusion_model"
return None
def adjust_metadata(self, metadata, file_path, root_path):
if hasattr(metadata, "model_type"):
if root_path in config.checkpoints_roots:
metadata.model_type = "checkpoint"
elif root_path in config.unet_roots:
metadata.model_type = "diffusion_model"
model_type = self._resolve_model_type(root_path)
if model_type:
metadata.model_type = model_type
return metadata
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
model_type = self._resolve_model_type(
self._find_root_for_file(entry.get("file_path"))
)
if model_type:
entry["model_type"] = model_type
return entry
def get_model_roots(self) -> List[str]:
"""Get checkpoint root directories"""
return config.base_models_roots
return config.base_models_roots

View File

@@ -11,13 +11,14 @@ logger = logging.getLogger(__name__)
class CheckpointService(BaseModelService):
"""Checkpoint-specific service implementation"""
def __init__(self, scanner):
def __init__(self, scanner, update_service=None):
"""Initialize Checkpoint service
Args:
scanner: Checkpoint scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("checkpoint", scanner, CheckpointMetadata)
super().__init__("checkpoint", scanner, CheckpointMetadata, update_service=update_service)
async def format_response(self, checkpoint_data: Dict) -> Dict:
"""Format Checkpoint data for API response"""
@@ -37,6 +38,7 @@ class CheckpointService(BaseModelService):
"notes": checkpoint_data.get("notes", ""),
"model_type": checkpoint_data.get("model_type", "checkpoint"),
"favorite": checkpoint_data.get("favorite", False),
"update_available": bool(checkpoint_data.get("update_available", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)
}
@@ -46,4 +48,4 @@ class CheckpointService(BaseModelService):
def find_duplicate_filenames(self) -> Dict:
"""Find Checkpoints with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames()
return self.scanner._hash_index.get_duplicate_filenames()

View File

@@ -0,0 +1,554 @@
import os
import json
import logging
import asyncio
from copy import deepcopy
from typing import Optional, Dict, Tuple, List
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
from .downloader import get_downloader
from .errors import RateLimitError
try:
from bs4 import BeautifulSoup
except ImportError as exc:
BeautifulSoup = None # type: ignore[assignment]
_BS4_IMPORT_ERROR = exc
else:
_BS4_IMPORT_ERROR = None
def _require_beautifulsoup():
if BeautifulSoup is None:
raise RuntimeError(
"BeautifulSoup (bs4) is required for CivArchive client. "
"Install it with 'pip install beautifulsoup4'."
) from _BS4_IMPORT_ERROR
return BeautifulSoup
logger = logging.getLogger(__name__)
class CivArchiveClient:
_instance = None
_lock = asyncio.Lock()
@classmethod
async def get_instance(cls):
"""Get singleton instance of CivArchiveClient"""
async with cls._lock:
if cls._instance is None:
cls._instance = cls()
# Register this client as a metadata provider
provider_manager = await ModelMetadataProviderManager.get_instance()
provider_manager.register_provider('civarchive', CivArchiveModelMetadataProvider(cls._instance), False)
return cls._instance
def __init__(self):
# Check if already initialized for singleton pattern
if hasattr(self, '_initialized'):
return
self._initialized = True
self.base_url = "https://civarchive.com/api"
async def _request_json(
self,
path: str,
params: Optional[Dict[str, str]] = None
) -> Tuple[Optional[Dict], Optional[str]]:
"""Call CivArchive API and return JSON payload"""
success, payload = await self._make_request(path, params=params)
if not success:
error = payload if isinstance(payload, str) else "Request failed"
return None, error
if not isinstance(payload, dict):
return None, "Invalid response structure"
return payload, None
async def _make_request(
self,
path: str,
*,
params: Optional[Dict[str, str]] = None,
) -> Tuple[bool, Dict | str]:
"""Wrapper around downloader.make_request that surfaces rate limits."""
downloader = await get_downloader()
kwargs: Dict[str, Dict[str, str]] = {}
if params:
safe_params = {str(key): str(value) for key, value in params.items() if value is not None}
if safe_params:
kwargs["params"] = safe_params
success, payload = await downloader.make_request(
"GET",
f"{self.base_url}{path}",
use_auth=False,
**kwargs,
)
if not success and isinstance(payload, RateLimitError):
if payload.provider is None:
payload.provider = "civarchive_api"
raise payload
return success, payload
@staticmethod
def _normalize_payload(payload: Dict) -> Dict:
"""Unwrap CivArchive responses that wrap content under a data key"""
if not isinstance(payload, dict):
return {}
data = payload.get("data")
if isinstance(data, dict):
return data
return payload
@staticmethod
def _split_context(payload: Dict) -> Tuple[Dict, Dict, List[Dict]]:
"""Separate version payload from surrounding model context"""
data = CivArchiveClient._normalize_payload(payload)
context: Dict = {}
fallback_files: List[Dict] = []
version: Dict = {}
for key, value in data.items():
if key in {"version", "model"}:
continue
context[key] = value
if isinstance(data.get("version"), dict):
version = data["version"]
model_block = data.get("model")
if isinstance(model_block, dict):
for key, value in model_block.items():
if key == "version":
if not version and isinstance(value, dict):
version = value
continue
context.setdefault(key, value)
fallback_files = fallback_files or model_block.get("files") or []
fallback_files = fallback_files or data.get("files") or []
return context, version, fallback_files
@staticmethod
def _ensure_list(value) -> List:
if isinstance(value, list):
return value
if value is None:
return []
return [value]
@staticmethod
def _build_model_info(context: Dict) -> Dict:
tags = context.get("tags")
if not isinstance(tags, list):
tags = list(tags) if isinstance(tags, (set, tuple)) else ([] if tags is None else [tags])
return {
"name": context.get("name"),
"type": context.get("type"),
"nsfw": bool(context.get("is_nsfw", context.get("nsfw", False))),
"description": context.get("description"),
"tags": tags,
}
@staticmethod
def _build_creator_info(context: Dict) -> Dict:
username = context.get("creator_username") or context.get("username") or ""
image = context.get("creator_image") or context.get("creator_avatar") or ""
creator: Dict[str, Optional[str]] = {
"username": username,
"image": image,
}
if context.get("creator_name"):
creator["name"] = context["creator_name"]
if context.get("creator_url"):
creator["url"] = context["creator_url"]
return creator
@staticmethod
def _transform_file_entry(file_data: Dict) -> Dict:
mirrors = file_data.get("mirrors") or []
if not isinstance(mirrors, list):
mirrors = [mirrors]
available_mirror = next(
(mirror for mirror in mirrors if isinstance(mirror, dict) and mirror.get("deletedAt") is None),
None
)
download_url = file_data.get("downloadUrl")
if not download_url and available_mirror:
download_url = available_mirror.get("url")
name = file_data.get("name")
if not name and available_mirror:
name = available_mirror.get("filename")
transformed: Dict = {
"id": file_data.get("id"),
"sizeKB": file_data.get("sizeKB"),
"name": name,
"type": file_data.get("type"),
"downloadUrl": download_url,
"primary": True,
# TODO: for some reason is_primary is false in CivArchive response, need to figure this out,
# "primary": bool(file_data.get("is_primary", file_data.get("primary", False))),
"mirrors": mirrors,
}
sha256 = file_data.get("sha256")
if sha256:
transformed["hashes"] = {"SHA256": str(sha256).upper()}
elif isinstance(file_data.get("hashes"), dict):
transformed["hashes"] = file_data["hashes"]
if "metadata" in file_data:
transformed["metadata"] = file_data["metadata"]
if file_data.get("modelVersionId") is not None:
transformed["modelVersionId"] = file_data.get("modelVersionId")
elif file_data.get("model_version_id") is not None:
transformed["modelVersionId"] = file_data.get("model_version_id")
if file_data.get("modelId") is not None:
transformed["modelId"] = file_data.get("modelId")
elif file_data.get("model_id") is not None:
transformed["modelId"] = file_data.get("model_id")
return transformed
def _transform_files(
self,
files: Optional[List[Dict]],
fallback_files: Optional[List[Dict]] = None
) -> List[Dict]:
candidates: List[Dict] = []
if isinstance(files, list) and files:
candidates = files
elif isinstance(fallback_files, list):
candidates = fallback_files
transformed_files: List[Dict] = []
for file_data in candidates:
if isinstance(file_data, dict):
transformed_files.append(self._transform_file_entry(file_data))
return transformed_files
def _transform_version(
self,
context: Dict,
version: Dict,
fallback_files: Optional[List[Dict]] = None
) -> Optional[Dict]:
if not version:
return None
version_copy = deepcopy(version)
version_copy.pop("model", None)
version_copy.pop("creator", None)
if "trigger" in version_copy:
triggers = version_copy.pop("trigger")
if isinstance(triggers, list):
version_copy["trainedWords"] = triggers
elif triggers is None:
version_copy["trainedWords"] = []
else:
version_copy["trainedWords"] = [triggers]
if "trainedWords" in version_copy and isinstance(version_copy["trainedWords"], str):
version_copy["trainedWords"] = [version_copy["trainedWords"]]
if "nsfw_level" in version_copy:
version_copy["nsfwLevel"] = version_copy.pop("nsfw_level")
elif "nsfwLevel" not in version_copy and context.get("nsfw_level") is not None:
version_copy["nsfwLevel"] = context.get("nsfw_level")
stats_keys = ["downloadCount", "ratingCount", "rating"]
stats = {key: version_copy.pop(key) for key in stats_keys if key in version_copy}
if stats:
version_copy["stats"] = stats
version_copy["files"] = self._transform_files(version_copy.get("files"), fallback_files)
version_copy["images"] = self._ensure_list(version_copy.get("images"))
version_copy["model"] = self._build_model_info(context)
version_copy["creator"] = self._build_creator_info(context)
version_copy["source"] = "civarchive"
version_copy["is_deleted"] = bool(context.get("deletedAt")) or bool(version.get("deletedAt"))
return version_copy
async def _resolve_version_from_files(self, payload: Dict) -> Optional[Dict]:
"""Fallback to fetch version data when only file metadata is available"""
data = self._normalize_payload(payload)
files = data.get("files") or payload.get("files") or []
if not isinstance(files, list):
files = [files]
for file_data in files:
if not isinstance(file_data, dict):
continue
model_id = file_data.get("model_id") or file_data.get("modelId")
version_id = file_data.get("model_version_id") or file_data.get("modelVersionId")
if model_id is None or version_id is None:
continue
resolved = await self.get_model_version(model_id, version_id)
if resolved:
return resolved
return None
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Find model by SHA256 hash value using CivArchive API"""
try:
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
if error:
if "not found" in error.lower():
return None, "Model not found"
return None, error
context, version_data, fallback_files = self._split_context(payload)
transformed = self._transform_version(context, version_data, fallback_files)
if transformed:
return transformed, None
resolved = await self._resolve_version_from_files(payload)
if resolved:
return resolved, None
logger.error("Error fetching version of CivArchive model by hash %s", model_hash[:10])
return None, "No version data found"
except RateLimitError:
raise
except Exception as e:
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
return None, str(e)
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Get all versions of a model using CivArchive API"""
try:
payload, error = await self._request_json(f"/models/{model_id}")
if error or payload is None:
if error and "not found" in error.lower():
return None
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
return None
data = self._normalize_payload(payload)
context, version_data, fallback_files = self._split_context(payload)
versions_meta = data.get("versions") or []
transformed_versions: List[Dict] = []
for meta in versions_meta:
if not isinstance(meta, dict):
continue
version_id = meta.get("id")
if version_id is None:
continue
target_model_id = meta.get("modelId") or model_id
version = await self.get_model_version(target_model_id, version_id)
if version:
transformed_versions.append(version)
# Ensure the primary version is included even if versions list was empty
primary_version = self._transform_version(context, version_data, fallback_files)
if primary_version:
transformed_versions.insert(0, primary_version)
ordered_versions: List[Dict] = []
seen_ids = set()
for version in transformed_versions:
version_id = version.get("id")
if version_id in seen_ids:
continue
seen_ids.add(version_id)
ordered_versions.append(version)
return {
"modelVersions": ordered_versions,
"type": context.get("type", ""),
"name": context.get("name", ""),
}
except RateLimitError:
raise
except Exception as e:
logger.error(f"Error fetching CivArchive model versions for {model_id}: {e}")
return None
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version using CivArchive API
Args:
model_id: The model ID (required)
version_id: Optional specific version ID to filter to
Returns:
Optional[Dict]: The model version data or None if not found
"""
if model_id is None:
return None
try:
params = {"modelVersionId": version_id} if version_id is not None else None
payload, error = await self._request_json(f"/models/{model_id}", params=params)
if error or payload is None:
if error and "not found" in error.lower():
return None
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
return None
context, version_data, fallback_files = self._split_context(payload)
if not version_data:
return await self._resolve_version_from_files(payload)
if version_id is not None:
raw_id = version_data.get("id")
if raw_id != version_id:
logger.warning(
"Requested version %s doesn't match default version %s for model %s",
version_id,
raw_id,
model_id,
)
return None
actual_model_id = version_data.get("modelId")
context_model_id = context.get("id")
# CivArchive can respond with data for a different model id while already
# returning the fully resolved model context. Only follow the redirect when
# the context itself still points to the original (wrong) model.
if (
actual_model_id is not None
and str(actual_model_id) != str(model_id)
and (context_model_id is None or str(context_model_id) != str(actual_model_id))
):
return await self.get_model_version(actual_model_id, version_id)
return self._transform_version(context, version_data, fallback_files)
except RateLimitError:
raise
except Exception as e:
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {e}")
return None
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
""" Fetch model version metadata using a known bogus model lookup
CivArchive lacks a direct version lookup API, this uses a workaround (which we handle in the main model request now)
Args:
version_id: The model version ID
Returns:
Tuple[Optional[Dict], Optional[str]]: (version_data, error_message)
"""
version = await self.get_model_version(1, version_id)
if version is None:
return None, "Model not found"
return version, None
async def get_model_by_url(self, url) -> Optional[Dict]:
"""Get specific model version by parsing CivArchive HTML page (legacy method)
This is the original HTML scraping implementation, kept for reference and new sites added not in api.
The primary get_model_version() now uses the API instead.
"""
try:
# Construct CivArchive URL
url = f"https://civarchive.com/{url}"
downloader = await get_downloader()
session = await downloader.session
async with session.get(url) as response:
if response.status != 200:
return None
html_content = await response.text()
# Parse HTML to extract JSON data
soup_parser = _require_beautifulsoup()
soup = soup_parser(html_content, 'html.parser')
script_tag = soup.find('script', {'id': '__NEXT_DATA__', 'type': 'application/json'})
if not script_tag:
return None
# Parse JSON content
json_data = json.loads(script_tag.string)
model_data = json_data.get('props', {}).get('pageProps', {}).get('model')
if not model_data or 'version' not in model_data:
return None
# Extract version data as base
version = model_data['version'].copy()
# Restructure stats
if 'downloadCount' in version and 'ratingCount' in version and 'rating' in version:
version['stats'] = {
'downloadCount': version.pop('downloadCount'),
'ratingCount': version.pop('ratingCount'),
'rating': version.pop('rating')
}
# Rename trigger to trainedWords
if 'trigger' in version:
version['trainedWords'] = version.pop('trigger')
# Transform files data to expected format
if 'files' in version:
transformed_files = []
for file_data in version['files']:
# Find first available mirror (deletedAt is null)
available_mirror = None
for mirror in file_data.get('mirrors', []):
if mirror.get('deletedAt') is None:
available_mirror = mirror
break
# Create transformed file entry
transformed_file = {
'id': file_data.get('id'),
'sizeKB': file_data.get('sizeKB'),
'name': available_mirror.get('filename', file_data.get('name')) if available_mirror else file_data.get('name'),
'type': file_data.get('type'),
'downloadUrl': available_mirror.get('url') if available_mirror else None,
'primary': file_data.get('is_primary', False),
'mirrors': file_data.get('mirrors', [])
}
# Transform hash format
if 'sha256' in file_data:
transformed_file['hashes'] = {
'SHA256': file_data['sha256'].upper()
}
transformed_files.append(transformed_file)
version['files'] = transformed_files
# Add model information
version['model'] = {
'name': model_data.get('name'),
'type': model_data.get('type'),
'nsfw': model_data.get('is_nsfw', False),
'description': model_data.get('description'),
'tags': model_data.get('tags', [])
}
version['creator'] = {
'username': model_data.get('username'),
'image': ''
}
# Add source identifier
version['source'] = 'civarchive'
version['is_deleted'] = json_data.get('query', {}).get('is_deleted', False)
return version
except RateLimitError:
raise
except Exception as e:
logger.error(f"Error fetching CivArchive model version (scraping) {url}: {e}")
return None

View File

@@ -1,10 +1,11 @@
import os
import asyncio
import copy
import logging
import asyncio
from typing import Optional, Dict, Tuple, List
import os
from typing import Any, Optional, Dict, Tuple, List, Sequence
from .model_metadata_provider import CivitaiModelMetadataProvider, ModelMetadataProviderManager
from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError
logger = logging.getLogger(__name__)
@@ -33,6 +34,29 @@ class CivitaiClient:
self.base_url = "https://civitai.com/api/v1"
async def _make_request(
self,
method: str,
url: str,
*,
use_auth: bool = False,
**kwargs,
) -> Tuple[bool, Dict | str]:
"""Wrapper around downloader.make_request that surfaces rate limits."""
downloader = await get_downloader()
success, result = await downloader.make_request(
method,
url,
use_auth=use_auth,
**kwargs,
)
if not success and isinstance(result, RateLimitError):
if result.provider is None:
result.provider = "civitai_api"
raise result
return success, result
@staticmethod
def _remove_comfy_metadata(model_version: Optional[Dict]) -> None:
"""Remove Comfy-specific metadata from model version images."""
@@ -79,8 +103,7 @@ class CivitaiClient:
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
try:
downloader = await get_downloader()
success, result = await downloader.make_request(
success, result = await self._make_request(
'GET',
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True
@@ -90,7 +113,7 @@ class CivitaiClient:
model_id = result.get('modelId')
if model_id:
# Fetch additional model metadata
success_model, data = await downloader.make_request(
success_model, data = await self._make_request(
'GET',
f"{self.base_url}/models/{model_id}",
use_auth=True
@@ -113,6 +136,8 @@ class CivitaiClient:
# Other error cases
logger.error(f"Failed to fetch model info for {model_hash[:10]}: {result}")
return None, str(result)
except RateLimitError:
raise
except Exception as e:
logger.error(f"API Error: {str(e)}")
return None, str(e)
@@ -135,11 +160,32 @@ class CivitaiClient:
logger.error(f"Download Error: {str(e)}")
return False
async def get_model_versions(self, model_id: str) -> List[Dict]:
@staticmethod
def _extract_error_message(payload: Any) -> str:
"""Return a human-readable error message from an API payload."""
def _from_value(value: Any) -> str:
if isinstance(value, str):
return value
if isinstance(value, dict):
for key in ("message", "error", "detail", "details"):
if key in value:
candidate = _from_value(value[key])
if candidate:
return candidate
if isinstance(value, list):
for item in value:
candidate = _from_value(item)
if candidate:
return candidate
return ""
return _from_value(payload)
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Get all versions of a model with local availability info"""
try:
downloader = await get_downloader()
success, result = await downloader.make_request(
success, result = await self._make_request(
'GET',
f"{self.base_url}/models/{model_id}",
use_auth=True
@@ -151,147 +197,229 @@ class CivitaiClient:
'type': result.get('type', ''),
'name': result.get('name', '')
}
message = self._extract_error_message(result)
if message and 'not found' in message.lower():
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
if message:
raise RuntimeError(message)
return None
except RateLimitError:
raise
except ResourceNotFoundError as exc:
logger.info("Model %s is no longer available on Civitai: %s", model_id, exc)
raise
except Exception as e:
logger.error(f"Error fetching model versions: {e}")
logger.error("Error fetching model versions: %s", e, exc_info=True)
raise
async def get_model_versions_bulk(
self, model_ids: Sequence[int]
) -> Optional[Dict[int, Dict]]:
"""Fetch model metadata for multiple ids using the batch API."""
deduped: Dict[int, None] = {}
for raw_id in model_ids:
try:
normalized = int(raw_id)
except (TypeError, ValueError):
continue
deduped.setdefault(normalized, None)
normalized_ids = [str(model_id) for model_id in deduped.keys()]
if not normalized_ids:
return {}
try:
query = ",".join(normalized_ids)
success, result = await self._make_request(
'GET',
f"{self.base_url}/models",
use_auth=True,
params={'ids': query},
)
if not success:
return None
items = result.get('items') if isinstance(result, dict) else None
if not isinstance(items, list):
return {}
payload: Dict[int, Dict] = {}
for item in items:
if not isinstance(item, dict):
continue
model_id = item.get('id')
try:
normalized_id = int(model_id)
except (TypeError, ValueError):
continue
payload[normalized_id] = {
'modelVersions': item.get('modelVersions', []),
'type': item.get('type', ''),
'name': item.get('name', ''),
}
return payload
except RateLimitError:
raise
except Exception as exc:
logger.error(f"Error fetching model versions in bulk: {exc}")
return None
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version with additional metadata
Args:
model_id: The Civitai model ID (optional if version_id is provided)
version_id: Optional specific version ID to retrieve
Returns:
Optional[Dict]: The model version data with additional fields or None if not found
"""
"""Get specific model version with additional metadata."""
try:
downloader = await get_downloader()
# Case 1: Only version_id is provided
if model_id is None and version_id is not None:
# First get the version info to extract model_id
success, version = await downloader.make_request(
'GET',
f"{self.base_url}/model-versions/{version_id}",
use_auth=True
)
if not success:
return None
model_id = version.get('modelId')
if not model_id:
logger.error(f"No modelId found in version {version_id}")
return None
# Now get the model data for additional metadata
success, model_data = await downloader.make_request(
'GET',
f"{self.base_url}/models/{model_id}",
use_auth=True
)
if success:
# Enrich version with model data
version['model']['description'] = model_data.get("description")
version['model']['tags'] = model_data.get("tags", [])
version['creator'] = model_data.get("creator")
return await self._get_version_by_id_only(version_id)
self._remove_comfy_metadata(version)
return version
# Case 2: model_id is provided (with or without version_id)
elif model_id is not None:
# Step 1: Get model data to find version_id if not provided and get additional metadata
success, data = await downloader.make_request(
'GET',
f"{self.base_url}/models/{model_id}",
use_auth=True
)
if not success:
return None
if model_id is not None:
return await self._get_version_with_model_id(model_id, version_id)
model_versions = data.get('modelVersions', [])
if not model_versions:
logger.warning(f"No model versions found for model {model_id}")
return None
logger.error("Either model_id or version_id must be provided")
return None
# Step 2: Determine the target version entry to use
target_version = None
if version_id is not None:
target_version = next(
(item for item in model_versions if item.get('id') == version_id),
None
)
if target_version is None:
logger.warning(
f"Version {version_id} not found for model {model_id}, defaulting to first version"
)
if target_version is None:
target_version = model_versions[0]
target_version_id = target_version.get('id')
# Step 3: Get detailed version info using the SHA256 hash
model_hash = None
for file_info in target_version.get('files', []):
if file_info.get('type') == 'Model' and file_info.get('primary'):
model_hash = file_info.get('hashes', {}).get('SHA256')
if model_hash:
break
version = None
if model_hash:
success, version = await downloader.make_request(
'GET',
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True
)
if not success:
logger.warning(
f"Failed to fetch version by hash for model {model_id} version {target_version_id}: {version}"
)
version = None
else:
logger.warning(
f"No primary model hash found for model {model_id} version {target_version_id}"
)
if version is None:
version = copy.deepcopy(target_version)
version.pop('index', None)
version['modelId'] = model_id
version['model'] = {
'name': data.get('name'),
'type': data.get('type'),
'nsfw': data.get('nsfw'),
'poi': data.get('poi')
}
# Step 4: Enrich version_info with model data
# Add description and tags from model data
model_info = version.get('model')
if not isinstance(model_info, dict):
model_info = {}
version['model'] = model_info
model_info['description'] = data.get("description")
model_info['tags'] = data.get("tags", [])
# Add creator from model data
version['creator'] = data.get("creator")
self._remove_comfy_metadata(version)
return version
# Case 3: Neither model_id nor version_id provided
else:
logger.error("Either model_id or version_id must be provided")
return None
except RateLimitError:
raise
except Exception as e:
logger.error(f"Error fetching model version: {e}")
return None
async def _get_version_by_id_only(self, version_id: int) -> Optional[Dict]:
version = await self._fetch_version_by_id(version_id)
if version is None:
return None
model_id = version.get('modelId')
if not model_id:
logger.error(f"No modelId found in version {version_id}")
return None
model_data = await self._fetch_model_data(model_id)
if model_data:
self._enrich_version_with_model_data(version, model_data)
self._remove_comfy_metadata(version)
return version
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)
if not model_data:
return None
target_version = self._select_target_version(model_data, model_id, version_id)
if target_version is None:
return None
target_version_id = target_version.get('id')
version = await self._fetch_version_by_id(target_version_id) if target_version_id else None
if version is None:
model_hash = self._extract_primary_model_hash(target_version)
if model_hash:
version = await self._fetch_version_by_hash(model_hash)
else:
logger.warning(
f"No primary model hash found for model {model_id} version {target_version_id}"
)
if version is None:
version = self._build_version_from_model_data(target_version, model_id, model_data)
self._enrich_version_with_model_data(version, model_data)
self._remove_comfy_metadata(version)
return version
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
success, data = await self._make_request(
'GET',
f"{self.base_url}/models/{model_id}",
use_auth=True
)
if success:
return data
logger.warning(f"Failed to fetch model data for model {model_id}")
return None
async def _fetch_version_by_id(self, version_id: Optional[int]) -> Optional[Dict]:
if version_id is None:
return None
success, version = await self._make_request(
'GET',
f"{self.base_url}/model-versions/{version_id}",
use_auth=True
)
if success:
return version
logger.warning(f"Failed to fetch version by id {version_id}")
return None
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict]:
if not model_hash:
return None
success, version = await self._make_request(
'GET',
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True
)
if success:
return version
logger.warning(f"Failed to fetch version by hash {model_hash}")
return None
def _select_target_version(self, model_data: Dict, model_id: int, version_id: Optional[int]) -> Optional[Dict]:
model_versions = model_data.get('modelVersions', [])
if not model_versions:
logger.warning(f"No model versions found for model {model_id}")
return None
if version_id is not None:
target_version = next(
(item for item in model_versions if item.get('id') == version_id),
None
)
if target_version is None:
logger.warning(
f"Version {version_id} not found for model {model_id}, defaulting to first version"
)
return model_versions[0]
return target_version
return model_versions[0]
def _extract_primary_model_hash(self, version_entry: Dict) -> Optional[str]:
for file_info in version_entry.get('files', []):
if file_info.get('type') == 'Model' and file_info.get('primary'):
hashes = file_info.get('hashes', {})
model_hash = hashes.get('SHA256')
if model_hash:
return model_hash
return None
def _build_version_from_model_data(self, version_entry: Dict, model_id: int, model_data: Dict) -> Dict:
version = copy.deepcopy(version_entry)
version.pop('index', None)
version['modelId'] = model_id
version['model'] = {
'name': model_data.get('name'),
'type': model_data.get('type'),
'nsfw': model_data.get('nsfw'),
'poi': model_data.get('poi')
}
return version
def _enrich_version_with_model_data(self, version: Dict, model_data: Dict) -> None:
model_info = version.get('model')
if not isinstance(model_info, dict):
model_info = {}
version['model'] = model_info
model_info['description'] = model_data.get("description")
model_info['tags'] = model_data.get("tags", [])
version['creator'] = model_data.get("creator")
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Fetch model version metadata from Civitai
@@ -304,11 +432,10 @@ class CivitaiClient:
- An error message if there was an error, or None on success
"""
try:
downloader = await get_downloader()
url = f"{self.base_url}/model-versions/{version_id}"
logger.debug(f"Resolving DNS for model version info: {url}")
success, result = await downloader.make_request(
success, result = await self._make_request(
'GET',
url,
use_auth=True
@@ -328,6 +455,8 @@ class CivitaiClient:
# Other error cases
logger.error(f"Failed to fetch model info for {version_id}: {result}")
return None, str(result)
except RateLimitError:
raise
except Exception as e:
error_msg = f"Error fetching model version info: {e}"
logger.error(error_msg)
@@ -335,7 +464,7 @@ class CivitaiClient:
async def get_image_info(self, image_id: str) -> Optional[Dict]:
"""Fetch image information from Civitai API
Args:
image_id: The Civitai image ID
@@ -343,11 +472,10 @@ class CivitaiClient:
Optional[Dict]: The image data or None if not found
"""
try:
downloader = await get_downloader()
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
logger.debug(f"Fetching image info for ID: {image_id}")
success, result = await downloader.make_request(
success, result = await self._make_request(
'GET',
url,
use_auth=True
@@ -362,7 +490,44 @@ class CivitaiClient:
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
return None
except RateLimitError:
raise
except Exception as e:
error_msg = f"Error fetching image info: {e}"
logger.error(error_msg)
return None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Fetch all models for a specific Civitai user."""
if not username:
return None
try:
url = f"{self.base_url}/models?username={username}"
success, result = await self._make_request(
'GET',
url,
use_auth=True
)
if not success:
logger.error("Failed to fetch models for %s: %s", username, result)
return None
items = result.get("items") if isinstance(result, dict) else None
if not isinstance(items, list):
return []
for model in items:
versions = model.get("modelVersions")
if not isinstance(versions, list):
continue
for version in versions:
self._remove_comfy_metadata(version)
return items
except RateLimitError:
raise
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error fetching models for %s: %s", username, exc)
return None

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import logging
from typing import Any, Awaitable, Callable, Dict, Optional
from .downloader import DownloadProgress
logger = logging.getLogger(__name__)
@@ -29,14 +31,40 @@ class DownloadCoordinator:
download_id = payload.get("download_id") or self._ws_manager.generate_download_id()
payload.setdefault("download_id", download_id)
async def progress_callback(progress: Any) -> None:
async def progress_callback(progress: Any, snapshot: Optional[DownloadProgress] = None) -> None:
percent = 0.0
metrics: Optional[DownloadProgress] = None
if isinstance(progress, DownloadProgress):
metrics = progress
percent = progress.percent_complete
elif isinstance(snapshot, DownloadProgress):
metrics = snapshot
percent = snapshot.percent_complete
else:
try:
percent = float(progress)
except (TypeError, ValueError):
percent = 0.0
payload: Dict[str, Any] = {
"status": "progress",
"progress": round(percent),
"download_id": download_id,
}
if metrics is not None:
payload.update(
{
"bytes_downloaded": metrics.bytes_downloaded,
"total_bytes": metrics.total_bytes,
"bytes_per_second": metrics.bytes_per_second,
}
)
await self._ws_manager.broadcast_download_progress(
download_id,
{
"status": "progress",
"progress": progress,
"download_id": download_id,
},
payload,
)
model_id = self._parse_optional_int(payload.get("model_id"), "model_id")
@@ -81,6 +109,56 @@ class DownloadCoordinator:
return result
async def pause_download(self, download_id: str) -> Dict[str, Any]:
"""Pause an active download and notify listeners."""
download_manager = await self._download_manager_factory()
result = await download_manager.pause_download(download_id)
if result.get("success"):
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
payload: Dict[str, Any] = {
"status": "paused",
"progress": cached_progress.get("progress", 0),
"download_id": download_id,
"message": "Download paused by user",
}
for field in ("bytes_downloaded", "total_bytes", "bytes_per_second"):
if field in cached_progress:
payload[field] = cached_progress[field]
payload["bytes_per_second"] = 0.0
await self._ws_manager.broadcast_download_progress(download_id, payload)
return result
async def resume_download(self, download_id: str) -> Dict[str, Any]:
"""Resume a paused download and notify listeners."""
download_manager = await self._download_manager_factory()
result = await download_manager.resume_download(download_id)
if result.get("success"):
cached_progress = self._ws_manager.get_download_progress(download_id) or {}
payload: Dict[str, Any] = {
"status": "downloading",
"progress": cached_progress.get("progress", 0),
"download_id": download_id,
"message": "Download resumed by user",
}
for field in ("bytes_downloaded", "total_bytes"):
if field in cached_progress:
payload[field] = cached_progress[field]
payload["bytes_per_second"] = cached_progress.get("bytes_per_second", 0.0)
await self._ws_manager.broadcast_download_progress(download_id, payload)
return result
async def list_active_downloads(self) -> Dict[str, Any]:
"""Return the active download map from the underlying manager."""

View File

@@ -1,17 +1,22 @@
import logging
import os
import asyncio
import inspect
from collections import OrderedDict
import uuid
from typing import Dict, List
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES, CIVITAI_MODEL_TAGS
from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry
from .settings_manager import settings
from .settings_manager import get_settings_manager
from .metadata_service import get_default_metadata_provider
from .downloader import get_downloader
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
# Download to temporary file first
import tempfile
@@ -40,6 +45,7 @@ class DownloadManager:
self._active_downloads = OrderedDict() # download_id -> download_info
self._download_semaphore = asyncio.Semaphore(5) # Limit concurrent downloads
self._download_tasks = {} # download_id -> asyncio.Task
self._pause_events: Dict[str, DownloadStreamControl] = {}
async def _get_lora_scanner(self):
"""Get the lora scanner from registry"""
@@ -80,13 +86,20 @@ class DownloadManager:
'model_id': model_id,
'model_version_id': model_version_id,
'progress': 0,
'status': 'queued'
'status': 'queued',
'bytes_downloaded': 0,
'total_bytes': None,
'bytes_per_second': 0.0,
'last_progress_timestamp': None,
}
pause_control = DownloadStreamControl()
self._pause_events[task_id] = pause_control
# Create tracking task
download_task = asyncio.create_task(
self._download_with_semaphore(
task_id, model_id, model_version_id, save_dir,
task_id, model_id, model_version_id, save_dir,
relative_path, progress_callback, use_default_paths, source
)
)
@@ -105,9 +118,10 @@ class DownloadManager:
# Clean up task reference
if task_id in self._download_tasks:
del self._download_tasks[task_id]
self._pause_events.pop(task_id, None)
async def _download_with_semaphore(self, task_id: str, model_id: int, model_version_id: int,
save_dir: str, relative_path: str,
save_dir: str, relative_path: str,
progress_callback=None, use_default_paths: bool = False,
source: str = None):
"""Execute download with semaphore to limit concurrency"""
@@ -117,15 +131,34 @@ class DownloadManager:
# Wrap progress callback to track progress in active_downloads
original_callback = progress_callback
async def tracking_callback(progress):
async def tracking_callback(progress, metrics=None):
progress_value, snapshot = self._normalize_progress(progress, metrics)
if task_id in self._active_downloads:
self._active_downloads[task_id]['progress'] = progress
info = self._active_downloads[task_id]
info['progress'] = round(progress_value)
if snapshot is not None:
info['bytes_downloaded'] = snapshot.bytes_downloaded
info['total_bytes'] = snapshot.total_bytes
info['bytes_per_second'] = snapshot.bytes_per_second
pause_control = self._pause_events.get(task_id)
if isinstance(pause_control, DownloadStreamControl):
pause_control.mark_progress(snapshot.timestamp)
info['last_progress_timestamp'] = pause_control.last_progress_timestamp
if original_callback:
await original_callback(progress)
await self._dispatch_progress(original_callback, snapshot, progress_value)
# Acquire semaphore to limit concurrent downloads
try:
async with self._download_semaphore:
pause_control = self._pause_events.get(task_id)
if pause_control is not None and pause_control.is_paused():
if task_id in self._active_downloads:
self._active_downloads[task_id]['status'] = 'paused'
self._active_downloads[task_id]['bytes_per_second'] = 0.0
await pause_control.wait()
# Update status to downloading
if task_id in self._active_downloads:
self._active_downloads[task_id]['status'] = 'downloading'
@@ -147,12 +180,14 @@ class DownloadManager:
self._active_downloads[task_id]['status'] = 'completed' if result['success'] else 'failed'
if not result['success']:
self._active_downloads[task_id]['error'] = result.get('error', 'Unknown error')
self._active_downloads[task_id]['bytes_per_second'] = 0.0
return result
except asyncio.CancelledError:
# Handle cancellation
if task_id in self._active_downloads:
self._active_downloads[task_id]['status'] = 'cancelled'
self._active_downloads[task_id]['bytes_per_second'] = 0.0
logger.info(f"Download cancelled for task {task_id}")
raise
except Exception as e:
@@ -161,6 +196,7 @@ class DownloadManager:
if task_id in self._active_downloads:
self._active_downloads[task_id]['status'] = 'failed'
self._active_downloads[task_id]['error'] = str(e)
self._active_downloads[task_id]['bytes_per_second'] = 0.0
return {'success': False, 'error': str(e)}
finally:
# Schedule cleanup of download record after delay
@@ -172,9 +208,17 @@ class DownloadManager:
if task_id in self._active_downloads:
del self._active_downloads[task_id]
async def _execute_original_download(self, model_id, model_version_id, save_dir,
relative_path, progress_callback, use_default_paths,
download_id=None, source=None):
async def _execute_original_download(
self,
model_id,
model_version_id,
save_dir,
relative_path,
progress_callback,
use_default_paths,
download_id=None,
source=None,
):
"""Wrapper for original download_from_civitai implementation"""
try:
# Check if model version already exists in library
@@ -195,13 +239,8 @@ class DownloadManager:
# Check embedding scanner
if await embedding_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'}
# Get metadata provider based on source parameter
if source == 'civarchive':
from .metadata_service import get_metadata_provider
metadata_provider = await get_metadata_provider('civarchive')
else:
metadata_provider = await get_default_metadata_provider()
metadata_provider = await get_default_metadata_provider()
# Get version info based on the provided identifier
version_info = await metadata_provider.get_model_version(model_id, model_version_id)
@@ -241,23 +280,24 @@ class DownloadManager:
# Handle use_default_paths
if use_default_paths:
settings_manager = get_settings_manager()
# Set save_dir based on model type
if model_type == 'checkpoint':
default_path = settings.get('default_checkpoint_root')
default_path = settings_manager.get('default_checkpoint_root')
if not default_path:
return {'success': False, 'error': 'Default checkpoint root path not set in settings'}
save_dir = default_path
elif model_type == 'lora':
default_path = settings.get('default_lora_root')
default_path = settings_manager.get('default_lora_root')
if not default_path:
return {'success': False, 'error': 'Default lora root path not set in settings'}
save_dir = default_path
elif model_type == 'embedding':
default_path = settings.get('default_embedding_root')
default_path = settings_manager.get('default_embedding_root')
if not default_path:
return {'success': False, 'error': 'Default embedding root path not set in settings'}
save_dir = default_path
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -291,7 +331,7 @@ class DownloadManager:
await progress_callback(0)
# 2. Get file information
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
file_info = next((f for f in version_info.get('files', []) if f.get('primary') and f.get('type') == 'Model'), None)
if not file_info:
return {'success': False, 'error': 'No primary file found in metadata'}
mirrors = file_info.get('mirrors') or []
@@ -306,7 +346,7 @@ class DownloadManager:
download_urls.append(download_url)
if not download_urls:
return {'success': False, 'error': 'No download URL found for primary file'}
return {'success': False, 'error': 'No mirror URL found'}
# 3. Prepare download
file_name = file_info['name']
@@ -332,9 +372,22 @@ class DownloadManager:
relative_path=relative_path,
progress_callback=progress_callback,
model_type=model_type,
download_id=download_id
download_id=download_id,
)
if result.get('success', False):
resolved_model_id = (
model_id
or version_info.get('modelId')
or (version_info.get('model') or {}).get('id')
)
await self._sync_downloaded_version(
model_type,
resolved_model_id,
version_info,
model_version_id,
)
# If early_access_msg exists and download failed, replace error message
if 'early_access_msg' in locals() and not result.get('success', False):
result['error'] = early_access_msg
@@ -349,6 +402,96 @@ class DownloadManager:
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
return {'success': False, 'error': str(e)}
async def _sync_downloaded_version(
self,
model_type: str,
model_id_value,
version_info: Dict,
fallback_version_id=None,
) -> None:
"""Ensure update tracking reflects a newly downloaded version."""
try:
update_service = await ServiceRegistry.get_model_update_service()
except Exception as exc:
logger.debug("Skipping update sync; failed to acquire update service: %s", exc)
return
if update_service is None:
return
resolved_model_id = model_id_value
if resolved_model_id is None:
resolved_model_id = version_info.get('modelId')
if resolved_model_id is None:
model_info = version_info.get('model')
if isinstance(model_info, dict):
resolved_model_id = model_info.get('id')
try:
resolved_model_id = int(resolved_model_id)
except (TypeError, ValueError):
logger.debug("Skipping update sync; invalid model id: %s", resolved_model_id)
return
version_id = version_info.get('id')
if version_id is None:
version_id = fallback_version_id
try:
version_id = int(version_id)
except (TypeError, ValueError):
logger.debug(
"Skipping update sync; invalid version id for model %s: %s",
resolved_model_id,
version_id,
)
return
version_ids = set()
scanner = None
try:
if model_type == 'lora':
scanner = await self._get_lora_scanner()
elif model_type == 'checkpoint':
scanner = await self._get_checkpoint_scanner()
elif model_type == 'embedding':
scanner = await ServiceRegistry.get_embedding_scanner()
except Exception as exc:
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
if scanner is not None:
try:
local_versions = await scanner.get_model_versions_by_id(resolved_model_id)
except Exception as exc:
logger.debug(
"Failed to collect local versions for %s model %s: %s",
model_type,
resolved_model_id,
exc,
)
else:
for entry in local_versions or []:
vid = entry.get('versionId')
try:
version_ids.add(int(vid))
except (TypeError, ValueError):
continue
version_ids.add(version_id)
try:
await update_service.update_in_library_versions(
model_type,
resolved_model_id,
sorted(version_ids),
)
except Exception as exc:
logger.debug(
"Failed to update in-library versions for %s model %s: %s",
model_type,
resolved_model_id,
exc,
)
def _calculate_relative_path(self, version_info: Dict, model_type: str = 'lora') -> str:
"""Calculate relative path using template from settings
@@ -360,7 +503,8 @@ class DownloadManager:
Relative path string
"""
# Get path template from settings for specific model type
path_template = settings.get_download_path_template(model_type)
settings_manager = get_settings_manager()
path_template = settings_manager.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure)
if not path_template:
@@ -377,38 +521,40 @@ class DownloadManager:
author = 'Anonymous'
# Apply mapping if available
base_model_mappings = settings.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)
model_info = version_info.get('model') or {}
# Get model tags
model_tags = version_info.get('model', {}).get('tags', [])
# Find the first Civitai model tag that exists in model_tags
first_tag = ''
for civitai_tag in CIVITAI_MODEL_TAGS:
if civitai_tag in model_tags:
first_tag = civitai_tag
break
# If no Civitai model tag found, fallback to first tag
if not first_tag and model_tags:
first_tag = model_tags[0]
model_tags = model_info.get('tags', [])
first_tag = settings_manager.resolve_priority_tag_for_model(model_tags, model_type)
# Format the template with available data
formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
formatted_path = formatted_path.replace('{model_name}', sanitize_folder_name(model_info.get('name', '')))
formatted_path = formatted_path.replace('{version_name}', sanitize_folder_name(version_info.get('name', '')))
if model_type == 'embedding':
formatted_path = formatted_path.replace(' ', '_')
return formatted_path
async def _execute_download(self, download_urls: List[str], save_dir: str,
metadata, version_info: Dict,
relative_path: str, progress_callback=None,
model_type: str = "lora", download_id: str = None) -> Dict:
async def _execute_download(
self,
download_urls: List[str],
save_dir: str,
metadata,
version_info: Dict,
relative_path: str,
progress_callback=None,
model_type: str = "lora",
download_id: str = None,
) -> Dict:
"""Execute the actual download process including preview images and model files"""
try:
# Extract original filename details
@@ -439,7 +585,9 @@ class DownloadManager:
part_path = save_path + '.part'
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
pause_control = self._pause_events.get(download_id) if download_id else None
# Store file paths in active_downloads for potential cleanup
if download_id and download_id in self._active_downloads:
self._active_downloads[download_id]['file_path'] = save_path
@@ -448,83 +596,140 @@ class DownloadManager:
# Download preview image if available
images = version_info.get('images', [])
if images:
# Report preview download progress
if progress_callback:
await progress_callback(1) # 1% progress for starting preview download
# Check if it's a video or an image
is_video = images[0].get('type') == 'video'
if (is_video):
# For videos, use .mp4 extension
preview_ext = '.mp4'
preview_path = os.path.splitext(save_path)[0] + preview_ext
# Download video directly using downloader
downloader = await get_downloader()
success, result = await downloader.download_file(
images[0]['url'],
preview_path,
use_auth=False # Preview images typically don't need auth
)
if success:
metadata.preview_url = preview_path.replace(os.sep, '/')
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
else:
# For images, use WebP format for better performance
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
temp_path = temp_file.name
# Download the original image to temp path using downloader
downloader = await get_downloader()
success, content, headers = await downloader.download_to_memory(
images[0]['url'],
use_auth=False
)
if success:
# Save to temp file
with open(temp_path, 'wb') as f:
f.write(content)
# Optimize and convert to WebP
preview_path = os.path.splitext(save_path)[0] + '.webp'
# Use ExifUtils to optimize and convert the image
optimized_data, _ = ExifUtils.optimize_image(
image_data=temp_path,
target_width=CARD_PREVIEW_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# Save the optimized image
with open(preview_path, 'wb') as f:
f.write(optimized_data)
# Update metadata
metadata.preview_url = preview_path.replace(os.sep, '/')
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
# Remove temporary file
try:
os.unlink(temp_path)
except Exception as e:
logger.warning(f"Failed to delete temp file: {e}")
settings_manager = get_settings_manager()
blur_mature_content = bool(
settings_manager.get('blur_mature_content', True)
)
selected_image, nsfw_level = select_preview_media(
images,
blur_mature_content=blur_mature_content,
)
preview_url = selected_image.get('url') if selected_image else None
media_type = (
(selected_image.get('type') or '').lower()
if selected_image
else ''
)
def _extension_from_url(url: str, fallback: str) -> str:
try:
parsed = urlparse(url)
except ValueError:
return fallback
ext = os.path.splitext(parsed.path)[1]
return ext or fallback
preview_downloaded = False
preview_path = None
if preview_url:
downloader = await get_downloader()
if media_type == 'video':
preview_ext = _extension_from_url(preview_url, '.mp4')
preview_path = os.path.splitext(save_path)[0] + preview_ext
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type='video')
attempt_urls: List[str] = []
if rewritten:
attempt_urls.append(rewritten_url)
attempt_urls.append(preview_url)
seen_attempts = set()
for attempt in attempt_urls:
if not attempt or attempt in seen_attempts:
continue
seen_attempts.add(attempt)
success, _ = await downloader.download_file(
attempt,
preview_path,
use_auth=False
)
if success:
preview_downloaded = True
break
else:
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type='image')
if rewritten:
preview_ext = _extension_from_url(preview_url, '.png')
preview_path = os.path.splitext(save_path)[0] + preview_ext
success, _ = await downloader.download_file(
rewritten_url,
preview_path,
use_auth=False
)
if success:
preview_downloaded = True
if not preview_downloaded:
temp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as temp_file:
temp_path = temp_file.name
success, content, _ = await downloader.download_to_memory(
preview_url,
use_auth=False
)
if success:
with open(temp_path, 'wb') as temp_file_handle:
temp_file_handle.write(content)
preview_path = os.path.splitext(save_path)[0] + '.webp'
optimized_data, _ = ExifUtils.optimize_image(
image_data=temp_path,
target_width=CARD_PREVIEW_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
with open(preview_path, 'wb') as preview_file:
preview_file.write(optimized_data)
preview_downloaded = True
finally:
if temp_path and os.path.exists(temp_path):
try:
os.unlink(temp_path)
except Exception as e:
logger.warning(f"Failed to delete temp file: {e}")
if preview_downloaded and preview_path:
metadata.preview_url = preview_path.replace(os.sep, '/')
metadata.preview_nsfw_level = nsfw_level
if download_id and download_id in self._active_downloads:
self._active_downloads[download_id]['preview_path'] = preview_path
# Report preview download completion
if progress_callback:
await progress_callback(3) # 3% progress after preview download
# Download model file with progress tracking using downloader
downloader = await get_downloader()
if pause_control is not None:
pause_control.update_stall_timeout(downloader.stall_timeout)
last_error = None
for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/")
download_kwargs = {
"progress_callback": lambda progress, snapshot=None: self._handle_download_progress(
progress,
progress_callback,
snapshot,
),
"use_auth": use_auth, # Only use authentication for Civitai downloads
}
if pause_control is not None:
download_kwargs["pause_event"] = pause_control
success, result = await downloader.download_file(
download_url,
save_path, # Use full path instead of separate dir and filename
progress_callback=lambda p: self._handle_download_progress(p, progress_callback),
use_auth=use_auth # Only use authentication for Civitai downloads
**download_kwargs,
)
if success:
@@ -559,10 +764,10 @@ class DownloadManager:
# 4. Update file information (size and modified time)
metadata.update_file_info(save_path)
# 5. Final metadata update
await MetadataManager.save_metadata(save_path, metadata)
scanner = None
adjust_root: Optional[str] = None
# 6. Update cache based on model type
# 5. Determine scanner and adjust metadata for cache consistency
if model_type == "checkpoint":
scanner = await self._get_checkpoint_scanner()
logger.info(f"Updating checkpoint cache for {save_path}")
@@ -572,9 +777,33 @@ class DownloadManager:
elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner()
logger.info(f"Updating embedding cache for {save_path}")
if scanner is not None:
file_path_for_adjust = getattr(metadata, "file_path", save_path)
if isinstance(file_path_for_adjust, str):
normalized_file_path = file_path_for_adjust.replace(os.sep, "/")
else:
normalized_file_path = str(file_path_for_adjust)
find_root = getattr(scanner, "_find_root_for_file", None)
if callable(find_root):
try:
adjust_root = find_root(normalized_file_path)
except TypeError:
adjust_root = None
adjust_metadata = getattr(scanner, "adjust_metadata", None)
if callable(adjust_metadata):
metadata = adjust_metadata(metadata, normalized_file_path, adjust_root)
# 6. Persist metadata with any adjustments
await MetadataManager.save_metadata(save_path, metadata)
# Convert metadata to dictionary
metadata_dict = metadata.to_dict()
adjust_cached_entry = getattr(scanner, "adjust_cached_entry", None) if scanner is not None else None
if callable(adjust_cached_entry):
metadata_dict = adjust_cached_entry(metadata_dict)
# Add model to cache and save to disk in a single operation
await scanner.add_model_to_cache(metadata_dict, relative_path)
@@ -603,21 +832,37 @@ class DownloadManager:
return {'success': False, 'error': str(e)}
async def _handle_download_progress(self, file_progress: float, progress_callback):
"""Convert file download progress to overall progress
Args:
file_progress: Progress of file download (0-100)
progress_callback: Callback function for progress updates
"""
if progress_callback:
# Scale file progress to 3-100 range (after preview download)
overall_progress = 3 + (file_progress * 0.97) # 97% of progress for file download
await progress_callback(round(overall_progress))
async def _handle_download_progress(
self,
progress_update,
progress_callback,
snapshot=None,
):
"""Convert file download progress to overall progress."""
if not progress_callback:
return
file_progress, original_snapshot = self._normalize_progress(progress_update, snapshot)
overall_progress = 3 + (file_progress * 0.97)
overall_progress = max(0.0, min(overall_progress, 100.0))
rounded_progress = round(overall_progress)
normalized_snapshot: Optional[DownloadProgress] = None
if original_snapshot is not None:
normalized_snapshot = DownloadProgress(
percent_complete=overall_progress,
bytes_downloaded=original_snapshot.bytes_downloaded,
total_bytes=original_snapshot.total_bytes,
bytes_per_second=original_snapshot.bytes_per_second,
timestamp=original_snapshot.timestamp,
)
await self._dispatch_progress(progress_callback, normalized_snapshot, rounded_progress)
async def cancel_download(self, download_id: str) -> Dict:
"""Cancel an active download by download_id
Args:
download_id: The unique identifier of the download task
@@ -631,10 +876,15 @@ class DownloadManager:
# Get the task and cancel it
task = self._download_tasks[download_id]
task.cancel()
pause_control = self._pause_events.get(download_id)
if pause_control is not None:
pause_control.resume()
# Update status in active downloads
if download_id in self._active_downloads:
self._active_downloads[download_id]['status'] = 'cancelling'
self._active_downloads[download_id]['bytes_per_second'] = 0.0
# Wait briefly for the task to acknowledge cancellation
try:
@@ -675,7 +925,15 @@ class DownloadManager:
except Exception as e:
logger.error(f"Error deleting metadata file: {e}")
# Delete preview file if exists (.webp or .mp4)
preview_path_value = download_info.get('preview_path')
if preview_path_value and os.path.exists(preview_path_value):
try:
os.unlink(preview_path_value)
logger.debug(f"Deleted preview file: {preview_path_value}")
except Exception as e:
logger.error(f"Error deleting preview file: {e}")
# Delete preview file if exists (.webp or .mp4) for legacy paths
for preview_ext in ['.webp', '.mp4']:
preview_path = os.path.splitext(file_path)[0] + preview_ext
if os.path.exists(preview_path):
@@ -689,7 +947,109 @@ class DownloadManager:
except Exception as e:
logger.error(f"Error cancelling download: {e}", exc_info=True)
return {'success': False, 'error': str(e)}
finally:
self._pause_events.pop(download_id, None)
async def pause_download(self, download_id: str) -> Dict:
"""Pause an active download without losing progress."""
if download_id not in self._download_tasks:
return {'success': False, 'error': 'Download task not found'}
pause_control = self._pause_events.get(download_id)
if pause_control is None:
return {'success': False, 'error': 'Download task not found'}
if pause_control.is_paused():
return {'success': False, 'error': 'Download is already paused'}
pause_control.pause()
download_info = self._active_downloads.get(download_id)
if download_info is not None:
download_info['status'] = 'paused'
download_info['bytes_per_second'] = 0.0
return {'success': True, 'message': 'Download paused successfully'}
async def resume_download(self, download_id: str) -> Dict:
"""Resume a previously paused download."""
pause_control = self._pause_events.get(download_id)
if pause_control is None:
return {'success': False, 'error': 'Download task not found'}
if pause_control.is_set():
return {'success': False, 'error': 'Download is not paused'}
download_info = self._active_downloads.get(download_id)
force_reconnect = False
if pause_control is not None:
elapsed = pause_control.time_since_last_progress()
threshold = max(30.0, pause_control.stall_timeout / 2.0)
if elapsed is not None and elapsed >= threshold:
force_reconnect = True
logger.info(
"Forcing reconnect for download %s after %.1f seconds without progress",
download_id,
elapsed,
)
pause_control.resume(force_reconnect=force_reconnect)
if download_info is not None:
if download_info.get('status') == 'paused':
download_info['status'] = 'downloading'
download_info.setdefault('bytes_per_second', 0.0)
return {'success': True, 'message': 'Download resumed successfully'}
@staticmethod
def _coerce_progress_value(progress) -> float:
try:
return float(progress)
except (TypeError, ValueError):
return 0.0
@classmethod
def _normalize_progress(
cls,
progress,
snapshot: Optional[DownloadProgress] = None,
) -> Tuple[float, Optional[DownloadProgress]]:
if isinstance(progress, DownloadProgress):
return progress.percent_complete, progress
if isinstance(snapshot, DownloadProgress):
return snapshot.percent_complete, snapshot
if isinstance(progress, dict):
if 'percent_complete' in progress:
return cls._coerce_progress_value(progress['percent_complete']), snapshot
if 'progress' in progress:
return cls._coerce_progress_value(progress['progress']), snapshot
return cls._coerce_progress_value(progress), None
async def _dispatch_progress(
self,
callback,
snapshot: Optional[DownloadProgress],
progress_value: float,
) -> None:
try:
if snapshot is not None:
result = callback(snapshot, snapshot)
else:
result = callback(progress_value)
except TypeError:
result = callback(progress_value)
if inspect.isawaitable(result):
await result
elif asyncio.iscoroutine(result):
await result
async def get_active_downloads(self) -> Dict:
"""Get information about all active downloads
@@ -704,8 +1064,11 @@ class DownloadManager:
'model_version_id': info.get('model_version_id'),
'progress': info.get('progress', 0),
'status': info.get('status', 'unknown'),
'error': info.get('error', None)
'error': info.get('error', None),
'bytes_downloaded': info.get('bytes_downloaded', 0),
'total_bytes': info.get('total_bytes'),
'bytes_per_second': info.get('bytes_per_second', 0.0),
}
for task_id, info in self._active_downloads.items()
]
}
}

View File

@@ -14,13 +14,95 @@ import os
import logging
import asyncio
import aiohttp
from datetime import datetime
from typing import Optional, Dict, Tuple, Callable, Union
from ..services.settings_manager import settings
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timedelta
from email.utils import parsedate_to_datetime
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
from ..services.settings_manager import get_settings_manager
from .errors import RateLimitError
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DownloadProgress:
"""Snapshot of a download transfer at a moment in time."""
percent_complete: float
bytes_downloaded: int
total_bytes: Optional[int]
bytes_per_second: float
timestamp: float
class DownloadStreamControl:
"""Synchronize pause/resume requests and reconnect hints for a download."""
def __init__(self, *, stall_timeout: Optional[float] = None) -> None:
self._event = asyncio.Event()
self._event.set()
self._reconnect_requested = False
self.last_progress_timestamp: Optional[float] = None
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0
def is_set(self) -> bool:
return self._event.is_set()
def is_paused(self) -> bool:
return not self._event.is_set()
def set(self) -> None:
self._event.set()
def clear(self) -> None:
self._event.clear()
async def wait(self) -> None:
await self._event.wait()
def pause(self) -> None:
self.clear()
def resume(self, *, force_reconnect: bool = False) -> None:
if force_reconnect:
self._reconnect_requested = True
self.set()
def request_reconnect(self) -> None:
self._reconnect_requested = True
self.set()
def has_reconnect_request(self) -> bool:
return self._reconnect_requested
def consume_reconnect_request(self) -> bool:
reconnect = self._reconnect_requested
self._reconnect_requested = False
return reconnect
def mark_progress(self, timestamp: Optional[float] = None) -> None:
self.last_progress_timestamp = timestamp or datetime.now().timestamp()
self._reconnect_requested = False
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]:
if self.last_progress_timestamp is None:
return None
reference = now if now is not None else datetime.now().timestamp()
return max(0.0, reference - self.last_progress_timestamp)
def update_stall_timeout(self, stall_timeout: float) -> None:
self.stall_timeout = float(stall_timeout)
class DownloadRestartRequested(Exception):
"""Raised when a caller explicitly requests a fresh HTTP stream."""
class DownloadStalledError(Exception):
"""Raised when download progress stalls beyond the configured timeout."""
class Downloader:
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
@@ -52,10 +134,14 @@ class Downloader:
self.max_retries = 5
self.base_delay = 2.0 # Base delay for exponential backoff
self.session_timeout = 300 # 5 minutes
self.stall_timeout = self._resolve_stall_timeout()
# Default headers
self.default_headers = {
'User-Agent': 'ComfyUI-LoRA-Manager/1.0'
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
# Explicitly request uncompressed payloads so aiohttp doesn't need optional
# decoders (e.g. zstandard) that may be missing in runtime environments.
'Accept-Encoding': 'identity',
}
@property
@@ -64,14 +150,38 @@ class Downloader:
if self._session is None or self._should_refresh_session():
await self._create_session()
return self._session
@property
def proxy_url(self) -> Optional[str]:
"""Get the current proxy URL (initialize if needed)"""
if not hasattr(self, '_proxy_url'):
self._proxy_url = None
return self._proxy_url
def _resolve_stall_timeout(self) -> float:
"""Determine the stall timeout from settings or environment."""
default_timeout = 120.0
settings_timeout = None
try:
settings_manager = get_settings_manager()
settings_timeout = settings_manager.get('download_stall_timeout_seconds')
except Exception as exc: # pragma: no cover - defensive guard
logger.debug("Failed to read stall timeout from settings: %s", exc)
raw_value = (
settings_timeout
if settings_timeout not in (None, "")
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT')
)
try:
timeout_value = float(raw_value)
except (TypeError, ValueError):
timeout_value = default_timeout
return max(30.0, timeout_value)
def _should_refresh_session(self) -> bool:
"""Check if session should be refreshed"""
if self._session is None:
@@ -94,12 +204,13 @@ class Downloader:
# Check for app-level proxy settings
proxy_url = None
if settings.get('proxy_enabled', False):
proxy_host = settings.get('proxy_host', '').strip()
proxy_port = settings.get('proxy_port', '').strip()
proxy_type = settings.get('proxy_type', 'http').lower()
proxy_username = settings.get('proxy_username', '').strip()
proxy_password = settings.get('proxy_password', '').strip()
settings_manager = get_settings_manager()
if settings_manager.get('proxy_enabled', False):
proxy_host = settings_manager.get('proxy_host', '').strip()
proxy_port = settings_manager.get('proxy_port', '').strip()
proxy_type = settings_manager.get('proxy_type', 'http').lower()
proxy_username = settings_manager.get('proxy_username', '').strip()
proxy_password = settings_manager.get('proxy_password', '').strip()
if proxy_host and proxy_port:
# Build proxy URL
@@ -146,7 +257,8 @@ class Downloader:
if use_auth:
# Add CivitAI API key if available
api_key = settings.get('civitai_api_key')
settings_manager = get_settings_manager()
api_key = settings_manager.get('civitai_api_key')
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
headers['Content-Type'] = 'application/json'
@@ -157,10 +269,11 @@ class Downloader:
self,
url: str,
save_path: str,
progress_callback: Optional[Callable[[float], None]] = None,
progress_callback: Optional[Callable[..., Awaitable[None]]] = None,
use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None,
allow_resume: bool = True
allow_resume: bool = True,
pause_event: Optional[DownloadStreamControl] = None,
) -> Tuple[bool, str]:
"""
Download a file with resumable downloads and retry mechanism
@@ -172,6 +285,7 @@ class Downloader:
use_auth: Whether to include authentication headers (e.g., CivitAI API key)
custom_headers: Additional headers to include in request
allow_resume: Whether to support resumable downloads
pause_event: Optional stream control used to pause/resume and request reconnects
Returns:
Tuple[bool, str]: (success, save_path or error message)
@@ -246,7 +360,16 @@ class Downloader:
if allow_resume:
os.rename(part_path, save_path)
if progress_callback:
await progress_callback(100)
await self._dispatch_progress_callback(
progress_callback,
DownloadProgress(
percent_complete=100.0,
bytes_downloaded=part_size,
total_bytes=actual_size,
bytes_per_second=0.0,
timestamp=datetime.now().timestamp(),
),
)
return True, save_path
# Remove corrupted part file and restart
os.remove(part_path)
@@ -274,36 +397,146 @@ class Downloader:
current_size = resume_offset
last_progress_report_time = datetime.now()
progress_samples: deque[tuple[datetime, int]] = deque()
progress_samples.append((last_progress_report_time, current_size))
# Ensure directory exists
os.makedirs(os.path.dirname(save_path), exist_ok=True)
# Stream download to file with progress updates
loop = asyncio.get_running_loop()
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb'
control = pause_event
if control is not None:
control.update_stall_timeout(self.stall_timeout)
with open(part_path, mode) as f:
async for chunk in response.content.iter_chunked(self.chunk_size):
if chunk:
# Run blocking file write in executor
await loop.run_in_executor(None, f.write, chunk)
current_size += len(chunk)
# Limit progress update frequency to reduce overhead
now = datetime.now()
time_diff = (now - last_progress_report_time).total_seconds()
if progress_callback and total_size and time_diff >= 1.0:
progress = (current_size / total_size) * 100
await progress_callback(progress)
last_progress_report_time = now
while True:
active_stall_timeout = control.stall_timeout if control else self.stall_timeout
if control is not None:
if control.is_paused():
await control.wait()
resume_time = datetime.now()
last_progress_report_time = resume_time
if control.consume_reconnect_request():
raise DownloadRestartRequested(
"Reconnect requested after resume"
)
elif control.consume_reconnect_request():
raise DownloadRestartRequested("Reconnect requested")
try:
chunk = await asyncio.wait_for(
response.content.read(self.chunk_size),
timeout=active_stall_timeout,
)
except asyncio.TimeoutError as exc:
logger.warning(
"Download stalled for %.1f seconds without progress from %s",
active_stall_timeout,
url,
)
raise DownloadStalledError(
f"No data received for {active_stall_timeout:.1f} seconds"
) from exc
if not chunk:
break
# Run blocking file write in executor
await loop.run_in_executor(None, f.write, chunk)
current_size += len(chunk)
now = datetime.now()
if control is not None:
control.mark_progress(timestamp=now.timestamp())
# Limit progress update frequency to reduce overhead
time_diff = (now - last_progress_report_time).total_seconds()
if progress_callback and time_diff >= 1.0:
progress_samples.append((now, current_size))
cutoff = now - timedelta(seconds=5)
while progress_samples and progress_samples[0][0] < cutoff:
progress_samples.popleft()
percent = (current_size / total_size) * 100 if total_size else 0.0
bytes_per_second = 0.0
if len(progress_samples) >= 2:
first_time, first_bytes = progress_samples[0]
last_time, last_bytes = progress_samples[-1]
elapsed = (last_time - first_time).total_seconds()
if elapsed > 0:
bytes_per_second = (last_bytes - first_bytes) / elapsed
progress_snapshot = DownloadProgress(
percent_complete=percent,
bytes_downloaded=current_size,
total_bytes=total_size or None,
bytes_per_second=bytes_per_second,
timestamp=now.timestamp(),
)
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
last_progress_report_time = now
# Download completed successfully
# Verify file size if total_size was provided
final_size = os.path.getsize(part_path)
if total_size > 0 and final_size != total_size:
logger.warning(f"File size mismatch. Expected: {total_size}, Got: {final_size}")
# Don't treat this as fatal error, continue anyway
# Verify file size integrity before finalizing
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0
expected_size = total_size if total_size > 0 else None
integrity_error: Optional[str] = None
if final_size <= 0:
integrity_error = "Downloaded file is empty"
elif expected_size is not None and final_size != expected_size:
integrity_error = (
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
)
if integrity_error is not None:
logger.error(
"Download integrity check failed for %s: %s",
save_path,
integrity_error,
)
# Remove the corrupted payload so future attempts start fresh
if os.path.exists(part_path):
try:
os.remove(part_path)
except OSError as remove_error:
logger.warning(
"Failed to delete corrupted download %s: %s",
part_path,
remove_error,
)
if part_path != save_path and os.path.exists(save_path):
try:
os.remove(save_path)
except OSError as remove_error:
logger.warning(
"Failed to delete target file %s after integrity error: %s",
save_path,
remove_error,
)
retry_count += 1
if retry_count <= self.max_retries:
delay = self.base_delay * (2 ** (retry_count - 1))
logger.info(
"Retrying download in %s seconds due to integrity check failure",
delay,
)
await asyncio.sleep(delay)
resume_offset = 0
total_size = 0
await self._create_session()
continue
return False, integrity_error
# Atomically rename .part to final file (only if using resume)
if allow_resume and part_path != save_path:
max_rename_attempts = 5
@@ -326,18 +559,34 @@ class Downloader:
else:
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
return False, f"Failed to finalize download: {str(e)}"
final_size = os.path.getsize(save_path)
# Ensure 100% progress is reported
if progress_callback:
await progress_callback(100)
final_snapshot = DownloadProgress(
percent_complete=100.0,
bytes_downloaded=final_size,
total_bytes=total_size or final_size,
bytes_per_second=0.0,
timestamp=datetime.now().timestamp(),
)
await self._dispatch_progress_callback(progress_callback, final_snapshot)
return True, save_path
except (aiohttp.ClientError, aiohttp.ClientPayloadError,
aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
except (
aiohttp.ClientError,
aiohttp.ClientPayloadError,
aiohttp.ServerDisconnectedError,
asyncio.TimeoutError,
DownloadStalledError,
DownloadRestartRequested,
) as e:
retry_count += 1
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}")
if retry_count <= self.max_retries:
# Calculate delay with exponential backoff
delay = self.base_delay * (2 ** (retry_count - 1))
@@ -361,7 +610,24 @@ class Downloader:
return False, str(e)
return False, f"Download failed after {self.max_retries + 1} attempts"
async def _dispatch_progress_callback(
self,
progress_callback: Callable[..., Awaitable[None]],
snapshot: DownloadProgress,
) -> None:
"""Invoke a progress callback while preserving backward compatibility."""
try:
result = progress_callback(snapshot, snapshot)
except TypeError:
result = progress_callback(snapshot.percent_complete)
if asyncio.iscoroutine(result):
await result
elif hasattr(result, "__await__"):
await result
async def download_to_memory(
self,
url: str,
@@ -511,6 +777,19 @@ class Downloader:
return False, "Access forbidden"
elif response.status == 404:
return False, "Resource not found"
elif response.status == 429:
retry_after = self._extract_retry_after(response.headers)
error_msg = "Request rate limited"
logger.warning(
"Rate limit encountered for %s %s; retry_after=%s",
method,
url,
retry_after,
)
return False, RateLimitError(
error_msg,
retry_after=retry_after,
)
else:
return False, f"Request failed with status {response.status}"
@@ -532,6 +811,38 @@ class Downloader:
await self._create_session()
logger.info("HTTP session refreshed due to settings change")
@staticmethod
def _extract_retry_after(headers) -> Optional[float]:
"""Parse the Retry-After header into seconds."""
if not headers:
return None
header_value = headers.get("Retry-After")
if not header_value:
return None
header_value = header_value.strip()
if not header_value:
return None
if header_value.isdigit():
try:
seconds = float(header_value)
except ValueError:
return None
return max(0.0, seconds)
try:
retry_datetime = parsedate_to_datetime(header_value)
except (TypeError, ValueError):
return None
if retry_datetime.tzinfo is None:
return None
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
return max(0.0, delta.total_seconds())
# Global instance accessor
async def get_downloader() -> Downloader:

View File

@@ -11,13 +11,14 @@ logger = logging.getLogger(__name__)
class EmbeddingService(BaseModelService):
"""Embedding-specific service implementation"""
def __init__(self, scanner):
def __init__(self, scanner, update_service=None):
"""Initialize Embedding service
Args:
scanner: Embedding scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("embedding", scanner, EmbeddingMetadata)
super().__init__("embedding", scanner, EmbeddingMetadata, update_service=update_service)
async def format_response(self, embedding_data: Dict) -> Dict:
"""Format Embedding data for API response"""
@@ -37,6 +38,7 @@ class EmbeddingService(BaseModelService):
"notes": embedding_data.get("notes", ""),
"model_type": embedding_data.get("model_type", "embedding"),
"favorite": embedding_data.get("favorite", False),
"update_available": bool(embedding_data.get("update_available", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)
}

27
py/services/errors.py Normal file
View File

@@ -0,0 +1,27 @@
"""Common service-level exception types."""
from __future__ import annotations
from typing import Optional
class RateLimitError(RuntimeError):
"""Raised when a remote provider rejects a request due to rate limiting."""
def __init__(
self,
message: str,
*,
retry_after: Optional[float] = None,
provider: Optional[str] = None,
) -> None:
super().__init__(message)
self.retry_after = retry_after
self.provider = provider
class ResourceNotFoundError(RuntimeError):
"""Raised when a remote resource is permanently missing."""
pass

View File

@@ -11,7 +11,7 @@ from pathlib import Path
from typing import Dict, List, Tuple
from .service_registry import ServiceRegistry
from .settings_manager import settings
from .settings_manager import get_settings_manager
from ..utils.example_images_paths import iter_library_roots
@@ -62,7 +62,8 @@ class ExampleImagesCleanupService:
async def cleanup_example_image_folders(self) -> Dict[str, object]:
"""Clean empty or orphaned example image folders by moving them under a deleted bucket."""
example_images_path = settings.get("example_images_path")
settings_manager = get_settings_manager()
example_images_path = settings_manager.get("example_images_path")
if not example_images_path:
logger.debug("Cleanup skipped: example images path not configured")
return {

View File

@@ -11,13 +11,14 @@ logger = logging.getLogger(__name__)
class LoraService(BaseModelService):
"""LoRA-specific service implementation"""
def __init__(self, scanner):
def __init__(self, scanner, update_service=None):
"""Initialize LoRA service
Args:
scanner: LoRA scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("lora", scanner, LoraMetadata)
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
async def format_response(self, lora_data: Dict) -> Dict:
"""Format LoRA data for API response"""
@@ -37,6 +38,7 @@ class LoraService(BaseModelService):
"usage_tips": lora_data.get("usage_tips", ""),
"notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False),
"update_available": bool(lora_data.get("update_available", False)),
"civitai": self.filter_civitai_data(lora_data.get("civitai", {}), minimal=True)
}
@@ -178,4 +180,4 @@ class LoraService(BaseModelService):
def find_duplicate_filenames(self) -> Dict:
"""Find LoRAs with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames()
return self.scanner._hash_index.get_duplicate_filenames()

View File

@@ -3,7 +3,7 @@ import logging
import asyncio
from pathlib import Path
from typing import Optional
from .downloader import get_downloader
from .downloader import get_downloader, DownloadProgress
logger = logging.getLogger(__name__)
@@ -77,9 +77,15 @@ class MetadataArchiveManager:
progress_callback("download", f"Downloading from {url}")
# Custom progress callback to report download progress
async def download_progress(progress):
async def download_progress(progress, snapshot=None):
if progress_callback:
progress_callback("download", f"Downloading archive... {progress:.1f}%")
if isinstance(progress, DownloadProgress):
percent = progress.percent_complete
elif isinstance(snapshot, DownloadProgress):
percent = snapshot.percent_complete
else:
percent = float(progress or 0)
progress_callback("download", f"Downloading archive... {percent:.1f}%")
success, result = await downloader.download_file(
url=url,

View File

@@ -1,12 +1,14 @@
import os
import logging
from .model_metadata_provider import (
ModelMetadataProvider,
ModelMetadataProviderManager,
SQLiteModelMetadataProvider,
CivitaiModelMetadataProvider,
CivArchiveModelMetadataProvider,
FallbackMetadataProvider
)
from .settings_manager import settings
from .settings_manager import get_settings_manager
from .metadata_archive_manager import MetadataArchiveManager
from .service_registry import ServiceRegistry
@@ -21,7 +23,8 @@ async def initialize_metadata_providers():
provider_manager.default_provider = None
# Get settings
enable_archive_db = settings.get('enable_metadata_archive_db', False)
settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
providers = []
@@ -53,26 +56,27 @@ async def initialize_metadata_providers():
except Exception as e:
logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
# Register CivArchive provider, but do NOT add to fallback providers
# Register CivArchive provider, and all add to fallback providers
try:
from .model_metadata_provider import CivArchiveModelMetadataProvider
civarchive_provider = CivArchiveModelMetadataProvider()
provider_manager.register_provider('civarchive', civarchive_provider)
logger.debug("CivArchive metadata provider registered (not included in fallback)")
civarchive_client = await ServiceRegistry.get_civarchive_client()
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
provider_manager.register_provider('civarchive_api', civarchive_provider)
providers.append(('civarchive_api', civarchive_provider))
logger.debug("CivArchive metadata provider registered (also included in fallback)")
except Exception as e:
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
# Set up fallback provider based on available providers
if len(providers) > 1:
# Always use Civitai API first, then Archive DB
ordered_providers = []
ordered_providers.extend([p[1] for p in providers if p[0] == 'civitai_api'])
ordered_providers.extend([p[1] for p in providers if p[0] == 'sqlite'])
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api'])
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api'])
ordered_providers.extend([p for p in providers if p[0] == 'sqlite'])
if ordered_providers:
fallback_provider = FallbackMetadataProvider(ordered_providers)
provider_manager.register_provider('fallback', fallback_provider, is_default=True)
logger.debug(f"Fallback metadata provider registered with {len(ordered_providers)} providers, Civitai API first")
elif len(providers) == 1:
# Only one provider available, set it as default
provider_name, provider = providers[0]
@@ -87,7 +91,8 @@ async def update_metadata_providers():
"""Update metadata providers based on current settings"""
try:
# Get current settings
enable_archive_db = settings.get('enable_metadata_archive_db', False)
settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
# Reinitialize all providers with new settings
provider_manager = await initialize_metadata_providers()

View File

@@ -10,6 +10,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
from ..services.settings_manager import SettingsManager
from ..utils.model_utils import determine_base_model
from .errors import RateLimitError
logger = logging.getLogger(__name__)
@@ -153,7 +154,12 @@ class MetadataSyncService:
model_data: Dict[str, Any],
update_cache_func: Callable[[str, str, Dict[str, Any]], Awaitable[bool]],
) -> tuple[bool, Optional[str]]:
"""Fetch metadata for a model and update both disk and cache state."""
"""Fetch metadata for a model and update both disk and cache state.
Callers should hydrate ``model_data`` via ``MetadataManager.hydrate_model_data``
before invoking this method so that the persisted payload retains all known
metadata fields.
"""
if not isinstance(model_data, dict):
error = f"Invalid model_data type: {type(model_data)}"
@@ -162,42 +168,118 @@ class MetadataSyncService:
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
enable_archive = self._settings.get("enable_metadata_archive_db", False)
previous_source = model_data.get("metadata_source") or (model_data.get("civitai") or {}).get("source")
try:
provider_attempts: list[tuple[Optional[str], MetadataProviderProtocol]] = []
sqlite_attempted = False
if model_data.get("civitai_deleted") is True:
if not enable_archive or model_data.get("db_checked") is True:
if previous_source in (None, "civarchive"):
try:
provider_attempts.append(("civarchive_api", await self._get_provider("civarchive_api")))
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug("Unable to resolve civarchive provider: %s", exc)
if enable_archive and model_data.get("db_checked") is not True:
try:
provider_attempts.append(("sqlite", await self._get_provider("sqlite")))
except Exception as exc: # pragma: no cover - provider resolution fault
logger.debug("Unable to resolve sqlite provider: %s", exc)
if not provider_attempts:
if not enable_archive:
error_msg = "CivitAI model is deleted and metadata archive DB is not enabled"
else:
elif model_data.get("db_checked") is True:
error_msg = "CivitAI model is deleted and not found in metadata archive DB"
return (False, error_msg)
metadata_provider = await self._get_provider("sqlite")
else:
error_msg = "CivitAI model is deleted and no archive provider is available"
return False, error_msg
else:
metadata_provider = await self._get_default_provider()
provider_attempts.append((None, await self._get_default_provider()))
civitai_metadata, error = await metadata_provider.get_model_by_hash(sha256)
if not civitai_metadata:
if error == "Model not found":
civitai_metadata: Optional[Dict[str, Any]] = None
metadata_provider: Optional[MetadataProviderProtocol] = None
provider_used: Optional[str] = None
last_error: Optional[str] = None
for provider_name, provider in provider_attempts:
try:
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
except RateLimitError as exc:
exc.provider = exc.provider or (provider_name or provider.__class__.__name__)
raise
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
civitai_metadata_candidate, error = None, str(exc)
if provider_name == "sqlite":
sqlite_attempted = True
if civitai_metadata_candidate:
civitai_metadata = civitai_metadata_candidate
metadata_provider = provider
provider_used = provider_name
break
last_error = error or last_error
if civitai_metadata is None or metadata_provider is None:
if sqlite_attempted:
model_data["db_checked"] = True
if last_error == "Model not found":
model_data["from_civitai"] = False
model_data["civitai_deleted"] = True
model_data["db_checked"] = enable_archive
model_data["db_checked"] = sqlite_attempted or (enable_archive and model_data.get("db_checked", False))
model_data["last_checked_at"] = datetime.now().timestamp()
data_to_save = model_data.copy()
data_to_save.pop("folder", None)
await self._metadata_manager.save_metadata(file_path, data_to_save)
default_error = (
"CivitAI model is deleted and metadata archive DB is not enabled"
if model_data.get("civitai_deleted") and not enable_archive
else "CivitAI model is deleted and not found in metadata archive DB"
if model_data.get("civitai_deleted") and (model_data.get("db_checked") is True or sqlite_attempted)
else "No provider returned metadata"
)
error_msg = (
f"Error fetching metadata: {error} (model_name={model_data.get('model_name', '')})"
f"Error fetching metadata: {last_error or default_error} "
f"(model_name={model_data.get('model_name', '')})"
)
logger.error(error_msg)
return False, error_msg
model_data["from_civitai"] = True
model_data["civitai_deleted"] = civitai_metadata.get("source") == "archive_db"
model_data["db_checked"] = enable_archive
model_data["civitai_deleted"] = civitai_metadata.get("source") == "archive_db" or civitai_metadata.get("source") == "civarchive"
model_data["db_checked"] = enable_archive and (
civitai_metadata.get("source") == "archive_db" or sqlite_attempted
)
source = civitai_metadata.get("source") or "civitai_api"
if source == "api":
source = "civitai_api"
elif provider_used == "civarchive_api" and source != "civarchive":
source = "civarchive"
elif provider_used == "sqlite":
source = "archive_db"
model_data["metadata_source"] = source
model_data["last_checked_at"] = datetime.now().timestamp()
readable_source = {
"civitai_api": "CivitAI API",
"civarchive": "CivArchive API",
"archive_db": "Archive Database",
}.get(source, source)
logger.info(
"Fetched metadata for %s via %s",
model_data.get("model_name", ""),
readable_source,
)
local_metadata = model_data.copy()
local_metadata.pop("folder", None)
@@ -221,6 +303,16 @@ class MetadataSyncService:
error_msg = f"Error fetching metadata - Missing key: {exc} in model_data={model_data}"
logger.error(error_msg)
return False, error_msg
except RateLimitError as exc:
provider_label = exc.provider or "metadata provider"
wait_hint = (
f"; retry after approximately {int(exc.retry_after)}s"
if exc.retry_after and exc.retry_after > 0
else ""
)
error_msg = f"Rate limited by {provider_label}{wait_hint}"
logger.warning(error_msg)
return False, error_msg
except Exception as exc: # pragma: no cover - error path
error_msg = f"Error fetching metadata: {exc}"
logger.error(error_msg, exc_info=True)
@@ -252,15 +344,6 @@ class MetadataSyncService:
+ (f" with version: {model_version_id}" if model_version_id else "")
)
primary_model_file: Optional[Dict[str, Any]] = None
for file_info in civitai_metadata.get("files", []):
if file_info.get("primary", False) and file_info.get("type") == "Model":
primary_model_file = file_info
break
if primary_model_file and primary_model_file.get("hashes", {}).get("SHA256"):
metadata["sha256"] = primary_model_file["hashes"]["SHA256"].lower()
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
await self.update_model_metadata(
metadata_path,

View File

@@ -1,6 +1,6 @@
import asyncio
from typing import List, Dict, Tuple
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted
@@ -15,19 +15,182 @@ SUPPORTED_SORT_MODES = [
('size', 'desc'),
]
DISPLAY_NAME_MODES = {"model_name", "file_name"}
@dataclass
class ModelCache:
"""Cache structure for model data with extensible sorting"""
"""Cache structure for model data with extensible sorting."""
raw_data: List[Dict]
folders: List[str]
version_index: Dict[int, Dict] = field(default_factory=dict)
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
name_display_mode: str = "model_name"
def __post_init__(self):
self._lock = asyncio.Lock()
# Cache for last sort: (sort_key, order) -> sorted list
self._last_sort: Tuple[str, str] = (None, None)
self._last_sorted_data: List[Dict] = []
self._normalize_raw_data()
self.name_display_mode = self._normalize_display_mode(self.name_display_mode)
# Default sort on init
asyncio.create_task(self.resort())
self.rebuild_version_index()
@staticmethod
def _normalize_display_mode(value: Optional[str]) -> str:
if isinstance(value, str) and value in DISPLAY_NAME_MODES:
return value
return "model_name"
@staticmethod
def _ensure_string(value: Any) -> str:
"""Return a safe string representation for metadata fields."""
if isinstance(value, str):
return value
if value is None:
return ""
return str(value)
def _normalize_item(self, item: Dict) -> None:
"""Ensure core metadata fields are present and string typed."""
if not isinstance(item, dict):
return
for field in ("model_name", "file_name", "folder"):
if field in item:
item[field] = self._ensure_string(item.get(field))
def _normalize_raw_data(self) -> None:
"""Normalize every cached entry before it is consumed."""
for item in self.raw_data:
self._normalize_item(item)
def _get_display_name(self, item: Dict) -> str:
"""Return the value used for name-based sorting based on display settings."""
if self.name_display_mode == "file_name":
primary = self._ensure_string(item.get("file_name"))
fallback = self._ensure_string(item.get("model_name"))
else:
primary = self._ensure_string(item.get("model_name"))
fallback = self._ensure_string(item.get("file_name"))
candidate = primary or fallback
return candidate or ""
@staticmethod
def _normalize_version_id(value: Any) -> Optional[int]:
"""Normalize a potential version identifier into an integer."""
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
return None
return None
def rebuild_version_index(self) -> None:
"""Rebuild the version and model indexes from the current raw data."""
self.version_index = {}
self.model_id_index = {}
for item in self.raw_data:
self.add_to_version_index(item)
def add_to_version_index(self, item: Dict) -> None:
"""Register a cache item in the version/model indexes if possible."""
civitai_data = item.get('civitai') if isinstance(item, dict) else None
if not isinstance(civitai_data, dict):
return
version_id = self._normalize_version_id(civitai_data.get('id'))
if version_id is None:
return
self.version_index[version_id] = item
model_id = self._normalize_version_id(civitai_data.get('modelId'))
if model_id is None:
return
descriptor = self._build_version_descriptor(item, civitai_data, version_id)
if descriptor is None:
return
versions = self.model_id_index.setdefault(model_id, [])
for index, existing in enumerate(versions):
if existing.get('versionId') == descriptor['versionId']:
versions[index] = descriptor
break
else:
versions.append(descriptor)
def remove_from_version_index(self, item: Dict) -> None:
"""Remove a cache item from the version/model indexes if present."""
civitai_data = item.get('civitai') if isinstance(item, dict) else None
if not isinstance(civitai_data, dict):
return
version_id = self._normalize_version_id(civitai_data.get('id'))
if version_id is None:
return
existing = self.version_index.get(version_id)
if existing is item or (
isinstance(existing, dict)
and existing.get('file_path') == item.get('file_path')
):
self.version_index.pop(version_id, None)
model_id = self._normalize_version_id(civitai_data.get('modelId'))
if model_id is None:
return
versions = self.model_id_index.get(model_id)
if not versions:
return
filtered = [v for v in versions if v.get('versionId') != version_id]
if filtered:
self.model_id_index[model_id] = filtered
else:
self.model_id_index.pop(model_id, None)
def _build_version_descriptor(
self,
item: Dict,
civitai_data: Dict[str, Any],
version_id: int,
) -> Optional[Dict[str, Any]]:
"""Create a lightweight descriptor for a version entry."""
model_name = self._ensure_string(civitai_data.get('name'))
file_name = self._ensure_string(item.get('file_name'))
return {
'versionId': version_id,
'name': model_name,
'fileName': file_name,
}
def get_versions_by_model_id(self, model_id: Any) -> List[Dict[str, Any]]:
"""Return cached version descriptors for a given model ID."""
normalized_id = self._normalize_version_id(model_id)
if normalized_id is None:
return []
versions = self.model_id_index.get(normalized_id, [])
return [dict(version) for version in versions]
async def resort(self):
"""Resort cached data according to last sort mode if set"""
@@ -39,17 +202,22 @@ class ModelCache:
# Update folder list
# else: do nothing
all_folders = set(l['folder'] for l in self.raw_data)
all_folders = {
self._ensure_string(item.get('folder'))
for item in self.raw_data
if isinstance(item, dict)
}
self.folders = sorted(list(all_folders), key=lambda x: x.lower())
self.rebuild_version_index()
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
"""Sort data by sort_key and order"""
reverse = (order == 'desc')
if sort_key == 'name':
# Natural sort by model_name, case-insensitive
# Natural sort by configured display name, case-insensitive
return natsorted(
data,
key=lambda x: x['model_name'].lower(),
key=lambda x: self._get_display_name(x).lower(),
reverse=reverse
)
elif sort_key == 'date':
@@ -80,6 +248,20 @@ class ModelCache:
self._last_sorted_data = sorted_data
return sorted_data
async def update_name_display_mode(self, display_mode: str) -> None:
"""Update the display mode used for name sorting and refresh cached results."""
normalized = self._normalize_display_mode(display_mode)
async with self._lock:
if self.name_display_mode == normalized:
return
self.name_display_mode = normalized
if self._last_sort[0] == 'name':
sort_key, order = self._last_sort
self._last_sorted_data = self._sort_data(self.raw_data, sort_key, order)
async def update_preview_url(self, file_path: str, preview_url: str, preview_nsfw_level: int) -> bool:
"""Update preview_url for a specific model in all cached data

View File

@@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
@@ -114,7 +114,8 @@ class ModelFileService:
raise ValueError('No model roots configured')
# Check if flat structure is configured for this model type
path_template = settings.get_download_path_template(self.model_type)
settings_manager = get_settings_manager()
path_template = settings_manager.get_download_path_template(self.model_type)
result.is_flat_structure = not path_template
# Initialize tracking

View File

@@ -236,10 +236,20 @@ class ModelLifecycleService:
def _get_multipart_ext(filename: str) -> str:
"""Return the extension for files with compound suffixes."""
parts = filename.split(".")
if len(parts) == 3:
return "." + ".".join(parts[-2:])
if len(parts) >= 4:
return "." + ".".join(parts[-3:])
return os.path.splitext(filename)[1]
known_suffixes = [
".metadata.json.bak",
".metadata.json",
".safetensors",
*PREVIEW_EXTENSIONS,
]
for suffix in sorted(known_suffixes, key=len, reverse=True):
if filename.endswith(suffix):
return suffix
basename = os.path.basename(filename)
dot_index = basename.find(".")
if dot_index != -1:
return basename[dot_index:]
return os.path.splitext(basename)[1]

View File

@@ -1,8 +1,11 @@
from abc import ABC, abstractmethod
import asyncio
import json
import logging
from typing import Optional, Dict, Tuple, Any
import random
from typing import Optional, Dict, Tuple, Any, List, Sequence
from .downloader import get_downloader
from .errors import RateLimitError
try:
from bs4 import BeautifulSoup
@@ -50,6 +53,12 @@ class ModelMetadataProvider(ABC):
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Get all versions of a model with their details"""
pass
async def get_model_versions_bulk(
self, model_ids: Sequence[int]
) -> Optional[Dict[int, Dict]]:
"""Fetch model versions for multiple model ids when supported."""
raise NotImplementedError
@abstractmethod
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
@@ -61,6 +70,11 @@ class ModelMetadataProvider(ABC):
"""Fetch model version metadata"""
pass
@abstractmethod
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Fetch models owned by the specified user"""
pass
class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata"""
@@ -72,6 +86,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
return await self.client.get_model_versions(model_id)
async def get_model_versions_bulk(
self, model_ids: Sequence[int]
) -> Optional[Dict[int, Dict]]:
return await self.client.get_model_versions_bulk(model_ids)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
return await self.client.get_model_version(model_id, version_id)
@@ -79,123 +98,30 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
return await self.client.get_model_version_info(version_id)
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
return await self.client.get_user_models(username)
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive HTML page parsing for metadata"""
"""Provider that uses CivArchive API for metadata"""
def __init__(self, civarchive_client):
self.client = civarchive_client
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Not supported by CivArchive provider"""
return None, "CivArchive provider does not support hash lookup"
return await self.client.get_model_by_hash(model_hash)
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Not supported by CivArchive provider"""
return None
return await self.client.get_model_versions(model_id)
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version by parsing CivArchive HTML page"""
if model_id is None or version_id is None:
return None
try:
# Construct CivArchive URL
url = f"https://civarchive.com/models/{model_id}?modelVersionId={version_id}"
downloader = await get_downloader()
session = await downloader.session
async with session.get(url) as response:
if response.status != 200:
return None
html_content = await response.text()
# Parse HTML to extract JSON data
soup_parser = _require_beautifulsoup()
soup = soup_parser(html_content, 'html.parser')
script_tag = soup.find('script', {'id': '__NEXT_DATA__', 'type': 'application/json'})
if not script_tag:
return None
# Parse JSON content
json_data = json.loads(script_tag.string)
model_data = json_data.get('props', {}).get('pageProps', {}).get('model')
if not model_data or 'version' not in model_data:
return None
# Extract version data as base
version = model_data['version'].copy()
# Restructure stats
if 'downloadCount' in version and 'ratingCount' in version and 'rating' in version:
version['stats'] = {
'downloadCount': version.pop('downloadCount'),
'ratingCount': version.pop('ratingCount'),
'rating': version.pop('rating')
}
# Rename trigger to trainedWords
if 'trigger' in version:
version['trainedWords'] = version.pop('trigger')
# Transform files data to expected format
if 'files' in version:
transformed_files = []
for file_data in version['files']:
# Find first available mirror (deletedAt is null)
available_mirror = None
for mirror in file_data.get('mirrors', []):
if mirror.get('deletedAt') is None:
available_mirror = mirror
break
# Create transformed file entry
transformed_file = {
'id': file_data.get('id'),
'sizeKB': file_data.get('sizeKB'),
'name': available_mirror.get('filename', file_data.get('name')) if available_mirror else file_data.get('name'),
'type': file_data.get('type'),
'downloadUrl': available_mirror.get('url') if available_mirror else None,
'primary': True,
'mirrors': file_data.get('mirrors', [])
}
# Transform hash format
if 'sha256' in file_data:
transformed_file['hashes'] = {
'SHA256': file_data['sha256'].upper()
}
transformed_files.append(transformed_file)
version['files'] = transformed_files
# Add model information
version['model'] = {
'name': model_data.get('name'),
'type': model_data.get('type'),
'nsfw': model_data.get('is_nsfw', False),
'description': model_data.get('description'),
'tags': model_data.get('tags', [])
}
version['creator'] = {
'username': model_data.get('username'),
'image': ''
}
# Add source identifier
version['source'] = 'civarchive'
version['is_deleted'] = json_data.get('query', {}).get('is_deleted', False)
return version
except Exception as e:
logger.error(f"Error fetching CivArchive model version {model_id}/{version_id}: {e}")
return None
return await self.client.get_model_version(model_id, version_id)
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
"""Not supported by CivArchive provider - requires both model_id and version_id"""
return None, "CivArchive provider requires both model_id and version_id"
return await self.client.get_model_version_info(version_id)
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Not supported by CivArchive provider"""
return None
class SQLiteModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses SQLite database for metadata"""
@@ -329,20 +255,24 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
"""Fetch model version metadata from SQLite database"""
async with self._aiosqlite.connect(self.db_path) as db:
db.row_factory = self._aiosqlite.Row
# Get version details
version_query = "SELECT model_id FROM model_versions WHERE id = ?"
cursor = await db.execute(version_query, (version_id,))
version_row = await cursor.fetchone()
if not version_row:
return None, "Model version not found"
model_id = version_row['model_id']
# Build complete version data with model info
version_data = await self._get_version_with_model_data(db, model_id, version_id)
return version_data, None
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
"""Listing models by username is not supported for archive database"""
return None
async def _get_version_with_model_data(self, db, model_id, version_id) -> Optional[Dict]:
"""Helper to build version data with model information"""
@@ -434,53 +364,166 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
class FallbackMetadataProvider(ModelMetadataProvider):
"""Try providers in order, return first successful result."""
def __init__(self, providers: list):
self.providers = providers
def __init__(
self,
providers: Sequence[ModelMetadataProvider | Tuple[str, ModelMetadataProvider]],
*,
rate_limit_retry_limit: int = 3,
rate_limit_base_delay: float = 1.5,
rate_limit_max_delay: float = 30.0,
rate_limit_jitter_ratio: float = 0.2,
) -> None:
self.providers: List[ModelMetadataProvider] = []
self._provider_labels: List[str] = []
for entry in providers:
if isinstance(entry, tuple) and len(entry) == 2:
name, provider = entry
else:
provider = entry
name = provider.__class__.__name__
self.providers.append(provider)
self._provider_labels.append(str(name))
self._rate_limit_retry_limit = max(1, rate_limit_retry_limit)
self._rate_limit_base_delay = rate_limit_base_delay
self._rate_limit_max_delay = rate_limit_max_delay
self._rate_limit_jitter_ratio = max(0.0, rate_limit_jitter_ratio)
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict], Optional[str]]:
for provider in self.providers:
for provider, label in self._iter_providers():
try:
result, error = await provider.get_model_by_hash(model_hash)
result, error = await self._call_with_rate_limit(
label,
provider.get_model_by_hash,
model_hash,
)
if result:
return result, error
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug(f"Provider failed for get_model_by_hash: {e}")
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
continue
return None, "Model not found"
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
for provider in self.providers:
for provider, label in self._iter_providers():
try:
result = await provider.get_model_versions(model_id)
result = await self._call_with_rate_limit(
label,
provider.get_model_versions,
model_id,
)
if result:
return result
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug(f"Provider failed for get_model_versions: {e}")
logger.debug("Provider %s failed for get_model_versions: %s", label, e)
continue
return None
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
for provider in self.providers:
for provider, label in self._iter_providers():
try:
result = await provider.get_model_version(model_id, version_id)
result = await self._call_with_rate_limit(
label,
provider.get_model_version,
model_id,
version_id,
)
if result:
return result
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug(f"Provider failed for get_model_version: {e}")
logger.debug("Provider %s failed for get_model_version: %s", label, e)
continue
return None
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict], Optional[str]]:
for provider in self.providers:
for provider, label in self._iter_providers():
try:
result, error = await provider.get_model_version_info(version_id)
result, error = await self._call_with_rate_limit(
label,
provider.get_model_version_info,
version_id,
)
if result:
return result, error
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug(f"Provider failed for get_model_version_info: {e}")
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
continue
return None, "No provider could retrieve the data"
async def get_user_models(self, username: str) -> Optional[List[Dict]]:
for provider, label in self._iter_providers():
try:
result = await self._call_with_rate_limit(
label,
provider.get_user_models,
username,
)
if result is not None:
return result
except RateLimitError as exc:
exc.provider = exc.provider or label
raise exc
except Exception as e:
logger.debug("Provider %s failed for get_user_models: %s", label, e)
continue
return None
def _iter_providers(self):
return zip(self.providers, self._provider_labels)
async def _call_with_rate_limit(
self,
label: str,
func,
*args,
**kwargs,
):
attempt = 0
while True:
try:
return await func(*args, **kwargs)
except RateLimitError as exc:
attempt += 1
if attempt >= self._rate_limit_retry_limit:
exc.provider = exc.provider or label
raise exc
delay = self._calculate_rate_limit_delay(exc.retry_after, attempt)
logger.warning(
"Provider %s rate limited request; retrying in %.2fs (attempt %s/%s)",
label,
delay,
attempt,
self._rate_limit_retry_limit,
)
await asyncio.sleep(delay)
except Exception:
raise
def _calculate_rate_limit_delay(self, retry_after: Optional[float], attempt: int) -> float:
if retry_after is not None:
return min(self._rate_limit_max_delay, max(0.0, retry_after))
base_delay = self._rate_limit_base_delay * (2 ** max(0, attempt - 1))
jitter_span = base_delay * self._rate_limit_jitter_ratio
if jitter_span > 0:
base_delay += random.uniform(-jitter_span, jitter_span)
return min(self._rate_limit_max_delay, max(0.0, base_delay))
class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers"""
@@ -512,7 +555,19 @@ class ModelMetadataProviderManager:
"""Get model versions using specified or default provider"""
provider = self._get_provider(provider_name)
return await provider.get_model_versions(model_id)
async def get_model_versions_bulk(
self,
model_ids: Sequence[int],
provider_name: str = None,
) -> Optional[Dict[int, Dict]]:
"""Fetch model versions for multiple model ids when supported by provider."""
provider = self._get_provider(provider_name)
try:
return await provider.get_model_versions_bulk(model_ids)
except NotImplementedError:
return None
async def get_model_version(self, model_id: int = None, version_id: int = None, provider_name: str = None) -> Optional[Dict]:
"""Get specific model version using specified or default provider"""
provider = self._get_provider(provider_name)
@@ -522,6 +577,11 @@ class ModelMetadataProviderManager:
"""Fetch model version info using specified or default provider"""
provider = self._get_provider(provider_name)
return await provider.get_model_version_info(version_id)
async def get_user_models(self, username: str, provider_name: str = None) -> Optional[List[Dict]]:
"""Fetch models owned by the specified user"""
provider = self._get_provider(provider_name)
return await provider.get_user_models(username)
def _get_provider(self, provider_name: str = None) -> ModelMetadataProvider:
"""Get provider by name or default provider"""

View File

@@ -187,6 +187,9 @@ class SearchStrategy:
return results
def _matches(self, candidate: str, search_term: str, search_lower: str, fuzzy: bool) -> bool:
if not isinstance(candidate, str):
candidate = "" if candidate is None else str(candidate)
if not candidate:
return False

View File

@@ -18,6 +18,7 @@ from .model_lifecycle_service import delete_model_artifacts
from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager
from .persistent_model_cache import get_persistent_cache
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
@@ -81,6 +82,13 @@ class ModelScanner:
self._is_initializing = False # Flag to track initialization state
self._excluded_models = [] # List to track excluded models
self._persistent_cache = get_persistent_cache()
self._name_display_mode = self._resolve_name_display_mode()
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
self._loop = loop
self.loop = loop
self._initialized = True
# Register this service
@@ -94,6 +102,7 @@ class ModelScanner:
self._tags_count = {}
self._excluded_models = []
self._is_initializing = False
self._name_display_mode = self._resolve_name_display_mode()
try:
loop = asyncio.get_running_loop()
@@ -101,8 +110,30 @@ class ModelScanner:
loop = None
if loop and not loop.is_closed():
self._loop = loop
self.loop = loop
loop.create_task(self.initialize_in_background())
def _resolve_name_display_mode(self) -> str:
"""Return the configured display mode for name sorting."""
try:
manager = get_settings_manager()
except Exception: # pragma: no cover - fallback to defaults
return "model_name"
value = manager.get("model_name_display", "model_name")
return ModelCache._normalize_display_mode(value)
async def on_model_name_display_changed(self, display_mode: str) -> None:
"""Handle updates to the model name display preference."""
normalized = ModelCache._normalize_display_mode(display_mode)
self._name_display_mode = normalized
if self._cache is not None:
await self._cache.update_name_display_mode(normalized)
async def _register_service(self):
"""Register this instance with the ServiceRegistry"""
service_name = f"{self.model_type}_scanner"
@@ -119,6 +150,12 @@ class ModelScanner:
if value not in (None, '', []):
slim[key] = value
creator = civitai.get('creator')
if isinstance(creator, Mapping):
username = creator.get('username')
if username:
slim['creator'] = {'username': username}
trained_words = civitai.get('trainedWords')
if trained_words:
slim['trainedWords'] = list(trained_words) if isinstance(trained_words, list) else trained_words
@@ -183,6 +220,7 @@ class ModelScanner:
'favorite': bool(get_value('favorite', False)),
'notes': notes,
'usage_tips': usage_tips,
'metadata_source': get_value('metadata_source', None),
'exclude': bool(get_value('exclude', False)),
'db_checked': bool(get_value('db_checked', False)),
'last_checked_at': float(get_value('last_checked_at', 0.0) or 0.0),
@@ -204,7 +242,8 @@ class ModelScanner:
if self._cache is None:
self._cache = ModelCache(
raw_data=[],
folders=[]
folders=[],
name_display_mode=self._name_display_mode,
)
# Set initializing flag to true
@@ -337,12 +376,16 @@ class ModelScanner:
hash_index.add_entry(sha_value.lower(), path)
tags_count: Dict[str, int] = {}
adjusted_raw_data: List[Dict[str, Any]] = []
for item in persisted.raw_data:
for tag in item.get('tags') or []:
adjusted_item = self.adjust_cached_entry(dict(item))
adjusted_raw_data.append(adjusted_item)
for tag in adjusted_item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
scan_result = CacheBuildResult(
raw_data=list(persisted.raw_data),
raw_data=adjusted_raw_data,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=list(persisted.excluded_models)
@@ -509,7 +552,8 @@ class ModelScanner:
if self._cache is None and not force_refresh:
return ModelCache(
raw_data=[],
folders=[]
folders=[],
name_display_mode=self._name_display_mode,
)
# If force refresh is requested, initialize the cache directly
@@ -542,7 +586,8 @@ class ModelScanner:
if self._cache is None:
self._cache = ModelCache(
raw_data=[],
folders=[]
folders=[],
name_display_mode=self._name_display_mode,
)
finally:
self._is_initializing = False # Unset flag
@@ -617,6 +662,7 @@ class ModelScanner:
for i in range(0, len(new_files), batch_size):
batch = new_files[i:i+batch_size]
for path in batch:
logger.info(f"{self.model_type.capitalize()} Scanner: Processing {path}")
try:
# Find the appropriate root path for this file
root_path = None
@@ -632,9 +678,13 @@ class ModelScanner:
if root_path:
model_data = await self._process_model_file(path, root_path)
if model_data:
model_data = self.adjust_cached_entry(dict(model_data))
if not model_data:
continue
# Add to cache
self._cache.raw_data.append(model_data)
self._cache.add_to_version_index(model_data)
# Update hash index if available
if 'sha256' in model_data and 'file_path' in model_data:
self._hash_index.add_entry(model_data['sha256'].lower(), model_data['file_path'])
@@ -661,7 +711,9 @@ class ModelScanner:
for path in missing_files:
try:
model_to_remove = path_to_item[path]
self._cache.remove_from_version_index(model_to_remove)
# Update tags count
for tag in model_to_remove.get('tags', []):
if tag in self._tags_count:
@@ -684,6 +736,8 @@ class ModelScanner:
all_folders = set(item.get('folder', '') for item in self._cache.raw_data)
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
self._cache.rebuild_version_index()
# Resort cache
await self._cache.resort()
@@ -719,6 +773,41 @@ class ModelScanner:
"""Hook for subclasses: adjust metadata during scanning"""
return metadata
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
return entry
@staticmethod
def _normalize_path_value(path: Optional[str]) -> str:
if not path:
return ''
normalized = os.path.normpath(path)
if normalized == '.':
return ''
return normalized.replace('\\', '/')
def _find_root_for_file(self, file_path: Optional[str]) -> Optional[str]:
"""Return the configured root directory that contains ``file_path``."""
normalized_path = self._normalize_path_value(file_path)
if not normalized_path:
return None
for root in self.get_model_roots() or []:
normalized_root = self._normalize_path_value(root)
if not normalized_root:
continue
if (
normalized_path == normalized_root
or normalized_path.startswith(f"{normalized_root}/")
):
return root
return None
async def _process_model_file(
self,
file_path: str,
@@ -824,11 +913,14 @@ class ModelScanner:
if self._cache is None:
self._cache = ModelCache(
raw_data=list(scan_result.raw_data),
folders=[]
folders=[],
name_display_mode=self._name_display_mode,
)
else:
self._cache.raw_data = list(scan_result.raw_data)
self._cache.rebuild_version_index()
await self._cache.resort()
async def _gather_model_data(
@@ -934,7 +1026,8 @@ class ModelScanner:
# Add to cache
self._cache.raw_data.append(metadata_dict)
self._cache.add_to_version_index(metadata_dict)
# Resort cache data
await self._cache.resort()
@@ -1076,6 +1169,9 @@ class ModelScanner:
cache = await self.get_cached_data()
existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
if existing_item:
cache.remove_from_version_index(existing_item)
if existing_item and 'tags' in existing_item:
for tag in existing_item.get('tags', []):
if tag in self._tags_count:
@@ -1106,6 +1202,7 @@ class ModelScanner:
)
cache.raw_data.append(cache_entry)
cache.add_to_version_index(cache_entry)
sha_value = cache_entry.get('sha256')
if sha_value:
@@ -1117,6 +1214,8 @@ class ModelScanner:
for tag in cache_entry.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
cache.rebuild_version_index()
await cache.resort()
if cache_modified:
@@ -1339,11 +1438,12 @@ class ModelScanner:
# Update hash index
for model in models_to_remove:
file_path = model['file_path']
self._cache.remove_from_version_index(model)
if hasattr(self, '_hash_index') and self._hash_index:
# Get the hash and filename before removal for duplicate checking
file_name = os.path.splitext(os.path.basename(file_path))[0]
hash_val = model.get('sha256', '').lower()
# Remove from hash index
self._hash_index.remove_by_path(file_path, hash_val)
@@ -1352,8 +1452,9 @@ class ModelScanner:
# Update cache data
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in file_paths]
# Resort cache
self._cache.rebuild_version_index()
await self._cache.resort()
await self._persist_current_cache()
@@ -1393,16 +1494,17 @@ class ModelScanner:
Returns:
bool: True if the model version exists, False otherwise
"""
try:
normalized_id = int(model_version_id)
except (TypeError, ValueError):
return False
try:
cache = await self.get_cached_data()
if not cache or not cache.raw_data:
if not cache:
return False
for item in cache.raw_data:
if item.get('civitai') and item['civitai'].get('id') == model_version_id:
return True
return False
return normalized_id in cache.version_index
except Exception as e:
logger.error(f"Error checking model version existence: {e}")
return False
@@ -1418,21 +1520,10 @@ class ModelScanner:
"""
try:
cache = await self.get_cached_data()
if not cache or not cache.raw_data:
if not cache:
return []
versions = []
for item in cache.raw_data:
if (item.get('civitai') and
item['civitai'].get('modelId') == model_id and
item['civitai'].get('id')):
versions.append({
'versionId': item['civitai'].get('id'),
'name': item['civitai'].get('name'),
'fileName': item.get('file_name', '')
})
return versions
return cache.get_versions_by_model_id(model_id)
except Exception as e:
logger.error(f"Error getting model versions: {e}")
return []

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import threading
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
from ..utils.settings_paths import get_settings_dir
from ..utils.settings_paths import get_project_root, get_settings_dir
logger = logging.getLogger(__name__)
@@ -25,6 +25,34 @@ class PersistentModelCache:
"""Persist core model metadata and hash index data in SQLite."""
_DEFAULT_FILENAME = "model_cache.sqlite"
_MODEL_COLUMNS: Tuple[str, ...] = (
"model_type",
"file_path",
"file_name",
"model_name",
"folder",
"size",
"modified",
"sha256",
"base_model",
"preview_url",
"preview_nsfw_level",
"from_civitai",
"favorite",
"notes",
"usage_tips",
"metadata_source",
"civitai_id",
"civitai_model_id",
"civitai_name",
"civitai_creator_username",
"trained_words",
"civitai_deleted",
"exclude",
"db_checked",
"last_checked_at",
)
_MODEL_UPDATE_COLUMNS: Tuple[str, ...] = _MODEL_COLUMNS[2:]
_instances: Dict[str, "PersistentModelCache"] = {}
_instance_lock = threading.Lock()
@@ -53,6 +81,11 @@ class PersistentModelCache:
def is_enabled(self) -> bool:
return os.environ.get("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "0") != "1"
def get_database_path(self) -> str:
"""Expose the resolved SQLite database path."""
return self._db_path
def load_cache(self, model_type: str) -> Optional[PersistedCacheData]:
if not self.is_enabled():
return None
@@ -64,12 +97,9 @@ class PersistentModelCache:
with self._db_lock:
conn = self._connect(readonly=True)
try:
model_columns_sql = ", ".join(self._MODEL_COLUMNS[1:])
rows = conn.execute(
"SELECT file_path, file_name, model_name, folder, size, modified, sha256, base_model,"
" preview_url, preview_nsfw_level, from_civitai, favorite, notes, usage_tips,"
" civitai_id, civitai_model_id, civitai_name, trained_words, exclude, db_checked,"
" last_checked_at"
" FROM models WHERE model_type = ?",
f"SELECT {model_columns_sql} FROM models WHERE model_type = ?",
(model_type,),
).fetchall()
@@ -101,8 +131,12 @@ class PersistentModelCache:
except json.JSONDecodeError:
trained_words = []
creator_username = row["civitai_creator_username"]
civitai: Optional[Dict] = None
if any(row[col] is not None for col in ("civitai_id", "civitai_model_id", "civitai_name")):
civitai_has_data = any(
row[col] is not None for col in ("civitai_id", "civitai_model_id", "civitai_name")
) or trained_words or creator_username
if civitai_has_data:
civitai = {}
if row["civitai_id"] is not None:
civitai["id"] = row["civitai_id"]
@@ -112,6 +146,8 @@ class PersistentModelCache:
civitai["name"] = row["civitai_name"]
if trained_words:
civitai["trainedWords"] = trained_words
if creator_username:
civitai.setdefault("creator", {})["username"] = creator_username
item = {
"file_path": file_path,
@@ -128,11 +164,13 @@ class PersistentModelCache:
"favorite": bool(row["favorite"]),
"notes": row["notes"] or "",
"usage_tips": row["usage_tips"] or "",
"metadata_source": row["metadata_source"] or None,
"exclude": bool(row["exclude"]),
"db_checked": bool(row["db_checked"]),
"last_checked_at": row["last_checked_at"] or 0.0,
"tags": tags.get(file_path, []),
"civitai": civitai,
"civitai_deleted": bool(row["civitai_deleted"]),
}
raw_data.append(item)
@@ -159,45 +197,190 @@ class PersistentModelCache:
conn = self._connect()
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("DELETE FROM models WHERE model_type = ?", (model_type,))
conn.execute("DELETE FROM model_tags WHERE model_type = ?", (model_type,))
conn.execute("DELETE FROM hash_index WHERE model_type = ?", (model_type,))
conn.execute("DELETE FROM excluded_models WHERE model_type = ?", (model_type,))
conn.execute("BEGIN")
model_rows = [self._prepare_model_row(model_type, item) for item in raw_data]
conn.executemany(self._insert_model_sql(), model_rows)
model_map: Dict[str, Tuple] = {
row[1]: row for row in model_rows if row[1] # row[1] is file_path
}
tag_rows = []
existing_models = conn.execute(
"SELECT "
+ ", ".join(self._MODEL_COLUMNS[1:])
+ " FROM models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_model_map: Dict[str, sqlite3.Row] = {
row["file_path"]: row for row in existing_models
}
to_remove_models = [
(model_type, path)
for path in existing_model_map.keys()
if path not in model_map
]
if to_remove_models:
conn.executemany(
"DELETE FROM models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
to_remove_models,
)
insert_rows: List[Tuple] = []
update_rows: List[Tuple] = []
for file_path, row in model_map.items():
existing = existing_model_map.get(file_path)
if existing is None:
insert_rows.append(row)
continue
existing_values = tuple(
existing[column] for column in self._MODEL_COLUMNS[1:]
)
current_values = row[1:]
if existing_values != current_values:
update_rows.append(row[2:] + (model_type, file_path))
if insert_rows:
conn.executemany(self._insert_model_sql(), insert_rows)
if update_rows:
set_clause = ", ".join(
f"{column} = ?"
for column in self._MODEL_UPDATE_COLUMNS
)
update_sql = (
f"UPDATE models SET {set_clause} WHERE model_type = ? AND file_path = ?"
)
conn.executemany(update_sql, update_rows)
existing_tags_rows = conn.execute(
"SELECT file_path, tag FROM model_tags WHERE model_type = ?",
(model_type,),
).fetchall()
existing_tags: Dict[str, set] = {}
for row in existing_tags_rows:
existing_tags.setdefault(row["file_path"], set()).add(row["tag"])
new_tags: Dict[str, set] = {}
for item in raw_data:
file_path = item.get("file_path")
if not file_path:
continue
for tag in item.get("tags") or []:
tag_rows.append((model_type, file_path, tag))
if tag_rows:
tags = set(item.get("tags") or [])
if tags:
new_tags[file_path] = tags
tag_inserts: List[Tuple[str, str, str]] = []
tag_deletes: List[Tuple[str, str, str]] = []
all_tag_paths = set(existing_tags.keys()) | set(new_tags.keys())
for path in all_tag_paths:
existing_set = existing_tags.get(path, set())
new_set = new_tags.get(path, set())
to_add = new_set - existing_set
to_remove = existing_set - new_set
for tag in to_add:
tag_inserts.append((model_type, path, tag))
for tag in to_remove:
tag_deletes.append((model_type, path, tag))
if tag_deletes:
conn.executemany(
"DELETE FROM model_tags WHERE model_type = ? AND file_path = ? AND tag = ?",
tag_deletes,
)
if tag_inserts:
conn.executemany(
"INSERT INTO model_tags (model_type, file_path, tag) VALUES (?, ?, ?)",
tag_rows,
tag_inserts,
)
hash_rows: List[Tuple[str, str, str]] = []
existing_hash_rows = conn.execute(
"SELECT sha256, file_path FROM hash_index WHERE model_type = ?",
(model_type,),
).fetchall()
existing_hash_map: Dict[str, set] = {}
for row in existing_hash_rows:
sha_value = (row["sha256"] or "").lower()
if not sha_value:
continue
existing_hash_map.setdefault(sha_value, set()).add(row["file_path"])
new_hash_map: Dict[str, set] = {}
for sha_value, paths in hash_index.items():
normalized_sha = (sha_value or "").lower()
if not normalized_sha:
continue
bucket = new_hash_map.setdefault(normalized_sha, set())
for path in paths:
if not sha_value or not path:
continue
hash_rows.append((model_type, sha_value.lower(), path))
if hash_rows:
if path:
bucket.add(path)
hash_inserts: List[Tuple[str, str, str]] = []
hash_deletes: List[Tuple[str, str, str]] = []
all_shas = set(existing_hash_map.keys()) | set(new_hash_map.keys())
for sha_value in all_shas:
existing_paths = existing_hash_map.get(sha_value, set())
new_paths = new_hash_map.get(sha_value, set())
for path in existing_paths - new_paths:
hash_deletes.append((model_type, sha_value, path))
for path in new_paths - existing_paths:
hash_inserts.append((model_type, sha_value, path))
if hash_deletes:
conn.executemany(
"DELETE FROM hash_index WHERE model_type = ? AND sha256 = ? AND file_path = ?",
hash_deletes,
)
if hash_inserts:
conn.executemany(
"INSERT OR IGNORE INTO hash_index (model_type, sha256, file_path) VALUES (?, ?, ?)",
hash_rows,
hash_inserts,
)
excluded_rows = [(model_type, path) for path in excluded_models]
if excluded_rows:
existing_excluded_rows = conn.execute(
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
existing_excluded = {row["file_path"] for row in existing_excluded_rows}
new_excluded = {path for path in excluded_models if path}
excluded_deletes = [
(model_type, path)
for path in existing_excluded - new_excluded
]
excluded_inserts = [
(model_type, path)
for path in new_excluded - existing_excluded
]
if excluded_deletes:
conn.executemany(
"DELETE FROM excluded_models WHERE model_type = ? AND file_path = ?",
excluded_deletes,
)
if excluded_inserts:
conn.executemany(
"INSERT OR IGNORE INTO excluded_models (model_type, file_path) VALUES (?, ?)",
excluded_rows,
excluded_inserts,
)
conn.commit()
finally:
conn.close()
@@ -214,7 +397,7 @@ class PersistentModelCache:
settings_dir = get_settings_dir(create=True)
except Exception as exc: # pragma: no cover - defensive guard
logger.warning("Falling back to project directory for cache: %s", exc)
settings_dir = os.path.dirname(os.path.dirname(self._db_path)) if hasattr(self, "_db_path") else os.getcwd()
settings_dir = get_project_root()
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", library_name or "default")
if safe_name.lower() in ("default", ""):
legacy_path = os.path.join(settings_dir, self._DEFAULT_FILENAME)
@@ -248,10 +431,13 @@ class PersistentModelCache:
favorite INTEGER,
notes TEXT,
usage_tips TEXT,
metadata_source TEXT,
civitai_id INTEGER,
civitai_model_id INTEGER,
civitai_name TEXT,
civitai_creator_username TEXT,
trained_words TEXT,
civitai_deleted INTEGER,
exclude INTEGER,
db_checked INTEGER,
last_checked_at REAL,
@@ -279,11 +465,31 @@ class PersistentModelCache:
);
"""
)
self._ensure_additional_model_columns(conn)
conn.commit()
self._schema_initialized = True
except Exception as exc: # pragma: no cover - defensive guard
logger.warning("Failed to initialize persistent cache schema: %s", exc)
def _ensure_additional_model_columns(self, conn: sqlite3.Connection) -> None:
try:
existing_columns = {
row["name"]
for row in conn.execute("PRAGMA table_info(models)").fetchall()
}
except Exception: # pragma: no cover - defensive guard
return
required_columns = {
"metadata_source": "TEXT",
"civitai_creator_username": "TEXT",
"civitai_deleted": "INTEGER DEFAULT 0",
}
for column, definition in required_columns.items():
if column not in existing_columns:
conn.execute(f"ALTER TABLE models ADD COLUMN {column} {definition}")
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
uri = False
path = self._db_path
@@ -306,6 +512,12 @@ class PersistentModelCache:
else:
trained_words_json = json.dumps(trained_words)
metadata_source = item.get("metadata_source") or None
creator_username = None
creator_data = civitai.get("creator") if isinstance(civitai, dict) else None
if isinstance(creator_data, dict):
creator_username = creator_data.get("username") or None
return (
model_type,
item.get("file_path"),
@@ -322,22 +534,22 @@ class PersistentModelCache:
1 if item.get("favorite") else 0,
item.get("notes"),
item.get("usage_tips"),
metadata_source,
civitai.get("id"),
civitai.get("modelId"),
civitai.get("name"),
creator_username,
trained_words_json,
1 if item.get("civitai_deleted") else 0,
1 if item.get("exclude") else 0,
1 if item.get("db_checked") else 0,
float(item.get("last_checked_at") or 0.0),
)
def _insert_model_sql(self) -> str:
return (
"INSERT INTO models (model_type, file_path, file_name, model_name, folder, size, modified, sha256,"
" base_model, preview_url, preview_nsfw_level, from_civitai, favorite, notes, usage_tips,"
" civitai_id, civitai_model_id, civitai_name, trained_words, exclude, db_checked, last_checked_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
columns = ", ".join(self._MODEL_COLUMNS)
placeholders = ", ".join(["?"] * len(self._MODEL_COLUMNS))
return f"INSERT INTO models ({columns}) VALUES ({placeholders})"
def _load_tags(self, conn: sqlite3.Connection, model_type: str) -> Dict[str, List[str]]:
tag_rows = conn.execute(
@@ -351,7 +563,7 @@ class PersistentModelCache:
def get_persistent_cache() -> PersistentModelCache:
from .settings_manager import settings as settings_service # Local import to avoid cycles
from .settings_manager import get_settings_manager # Local import to avoid cycles
library_name = settings_service.get_active_library_name()
library_name = get_settings_manager().get_active_library_name()
return PersistentModelCache.get_default(library_name)

View File

@@ -5,8 +5,12 @@ from __future__ import annotations
import logging
import os
from typing import Awaitable, Callable, Dict, Optional, Sequence
from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
@@ -41,27 +45,74 @@ class PreviewAssetService:
if not images:
return
first_preview = images[0]
settings_manager = get_settings_manager()
blur_mature_content = bool(
settings_manager.get("blur_mature_content", True)
)
first_preview, nsfw_level = select_preview_media(
images,
blur_mature_content=blur_mature_content,
)
if not first_preview:
return
base_name = os.path.splitext(os.path.splitext(os.path.basename(metadata_path))[0])[0]
preview_dir = os.path.dirname(metadata_path)
is_video = first_preview.get("type") == "video"
preview_url = first_preview.get("url")
if not preview_url:
return
def extension_from_url(url: str, fallback: str) -> str:
try:
parsed = urlparse(url)
except ValueError:
return fallback
ext = os.path.splitext(parsed.path)[1]
return ext or fallback
downloader = await self._downloader_factory()
if is_video:
extension = ".mp4"
extension = extension_from_url(preview_url, ".mp4")
preview_path = os.path.join(preview_dir, base_name + extension)
downloader = await self._downloader_factory()
success, result = await downloader.download_file(
first_preview["url"], preview_path, use_auth=False
)
if success:
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
local_metadata["preview_nsfw_level"] = first_preview.get("nsfwLevel", 0)
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type="video")
attempt_urls = []
if rewritten:
attempt_urls.append(rewritten_url)
attempt_urls.append(preview_url)
seen: set[str] = set()
for candidate in attempt_urls:
if not candidate or candidate in seen:
continue
seen.add(candidate)
success, _ = await downloader.download_file(candidate, preview_path, use_auth=False)
if success:
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
local_metadata["preview_nsfw_level"] = nsfw_level
return
else:
rewritten_url, rewritten = rewrite_preview_url(preview_url, media_type="image")
if rewritten:
extension = extension_from_url(preview_url, ".png")
preview_path = os.path.join(preview_dir, base_name + extension)
success, _ = await downloader.download_file(
rewritten_url, preview_path, use_auth=False
)
if success:
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
local_metadata["preview_nsfw_level"] = nsfw_level
return
extension = ".webp"
preview_path = os.path.join(preview_dir, base_name + extension)
downloader = await self._downloader_factory()
success, content, _headers = await downloader.download_to_memory(
first_preview["url"], use_auth=False
preview_url, use_auth=False
)
if not success:
return
@@ -86,7 +137,7 @@ class PreviewAssetService:
return
local_metadata["preview_url"] = preview_path.replace(os.sep, "/")
local_metadata["preview_nsfw_level"] = first_preview.get("nsfwLevel", 0)
local_metadata["preview_nsfw_level"] = nsfw_level
async def replace_preview(
self,

View File

@@ -384,16 +384,32 @@ class RecipeScanner:
# Ensure the image file exists
image_path = recipe_data.get('file_path')
if not os.path.exists(image_path):
normalized_image_path = os.path.normpath(image_path) if image_path else image_path
path_updated = False
if image_path and normalized_image_path != image_path:
recipe_data['file_path'] = normalized_image_path
image_path = normalized_image_path
path_updated = True
if image_path and not os.path.exists(image_path):
logger.warning(f"Recipe image not found: {image_path}")
# Try to find the image in the same directory as the recipe
recipe_dir = os.path.dirname(recipe_path)
image_filename = os.path.basename(image_path)
alternative_path = os.path.join(recipe_dir, image_filename)
if os.path.exists(alternative_path):
recipe_data['file_path'] = alternative_path
normalized_alternative = os.path.normpath(alternative_path)
recipe_data['file_path'] = normalized_alternative
image_path = normalized_alternative
path_updated = True
logger.info(
"Updated recipe image path to %s after relocating asset", normalized_alternative
)
else:
logger.warning(f"Could not find alternative image path for {image_path}")
if path_updated:
self._write_recipe_file(recipe_path, recipe_data)
# Ensure loras array exists
if 'loras' not in recipe_data:
@@ -413,18 +429,24 @@ class RecipeScanner:
# Write updated recipe data back to file
try:
with open(recipe_path, 'w', encoding='utf-8') as f:
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
self._write_recipe_file(recipe_path, recipe_data)
logger.info(f"Added fingerprint to recipe: {recipe_path}")
except Exception as e:
logger.error(f"Error writing updated recipe with fingerprint: {e}")
return recipe_data
except Exception as e:
logger.error(f"Error loading recipe file {recipe_path}: {e}")
import traceback
traceback.print_exc(file=sys.stderr)
return None
@staticmethod
def _write_recipe_file(recipe_path: str, recipe_data: Dict[str, Any]) -> None:
"""Persist ``recipe_data`` back to ``recipe_path`` with standard formatting."""
with open(recipe_path, 'w', encoding='utf-8') as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
async def _update_lora_information(self, recipe_data: Dict) -> bool:
"""Update LoRA information with hash and file_name
@@ -625,6 +647,17 @@ class RecipeScanner:
# Get base dataset
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
# Apply SFW filtering if enabled
from .settings_manager import get_settings_manager
settings = get_settings_manager()
if settings.get("show_only_sfw", False):
from ..utils.constants import NSFW_LEVELS
threshold = NSFW_LEVELS.get("R", 4) # Default to R level (4) if not found
filtered_data = [
item for item in filtered_data
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold
]
# Special case: Filter by LoRA hash (takes precedence if bypass_filters is True)
if lora_hash:
# Filter recipes that contain this LoRA hash

View File

@@ -73,7 +73,8 @@ class RecipePersistenceService:
)
image_filename = f"{recipe_id}{extension}"
image_path = os.path.join(recipes_dir, image_filename)
with open(image_path, "wb") as file_obj:
normalized_image_path = os.path.normpath(image_path)
with open(normalized_image_path, "wb") as file_obj:
file_obj.write(optimized_image)
current_time = time.time()
@@ -97,7 +98,7 @@ class RecipePersistenceService:
fingerprint = calculate_recipe_fingerprint(loras_data)
recipe_data: Dict[str, Any] = {
"id": recipe_id,
"file_path": image_path,
"file_path": normalized_image_path,
"title": name,
"modified": current_time,
"created_date": current_time,
@@ -116,10 +117,11 @@ class RecipePersistenceService:
json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename)
json_path = os.path.normpath(json_path)
with open(json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
await recipe_scanner.add_recipe(recipe_data)
@@ -128,7 +130,7 @@ class RecipePersistenceService:
{
"success": True,
"recipe_id": recipe_id,
"image_path": image_path,
"image_path": normalized_image_path,
"json_path": json_path,
"matching_recipes": matching_recipes,
}

View File

@@ -2,9 +2,11 @@
from __future__ import annotations
import os
import re
import shutil
import tempfile
import time
import unicodedata
from dataclasses import dataclass
from typing import Any, Dict
@@ -59,9 +61,10 @@ class RecipeSharingService:
}
self._cleanup_shared_recipes()
safe_title = recipe.get("title", "").replace(" ", "_").lower()
filename = f"recipe_{safe_title}{ext}" if safe_title else f"recipe_{recipe_id}{ext}"
url_path = f"/api/recipe/{recipe_id}/share/download?t={timestamp}"
filename = self._build_download_filename(
title=recipe.get("title", ""), recipe_id=recipe_id, ext=ext
)
url_path = f"/api/lm/recipe/{recipe_id}/share/download?t={timestamp}"
return SharingResult({"success": True, "download_url": url_path, "filename": filename})
async def prepare_download(self, *, recipe_scanner, recipe_id: str) -> DownloadInfo:
@@ -78,13 +81,38 @@ class RecipeSharingService:
raise RecipeNotFoundError("Shared recipe file not found")
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
filename_base = (
f"recipe_{recipe.get('title', '').replace(' ', '_').lower()}" if recipe else recipe_id
)
ext = os.path.splitext(file_path)[1]
download_filename = f"{filename_base}{ext}"
download_filename = self._build_download_filename(
title=recipe.get("title", "") if recipe else "",
recipe_id=recipe_id,
ext=ext,
)
return DownloadInfo(file_path=file_path, download_filename=download_filename)
@staticmethod
def _build_download_filename(*, title: str, recipe_id: str, ext: str) -> str:
"""Generate a sanitized filename safe for HTTP headers and filesystems."""
ext = ext or ""
safe_title = RecipeSharingService._slugify(title)
fallback = RecipeSharingService._slugify(recipe_id)
identifier = safe_title or fallback or "recipe"
return f"recipe_{identifier}{ext}"
@staticmethod
def _slugify(value: str) -> str:
"""Convert arbitrary input into a lowercase, header-safe slug."""
if not value:
return ""
normalized = unicodedata.normalize("NFKD", value)
ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
ascii_value = ascii_value.replace("\n", " ").replace("\r", " ")
sanitized = re.sub(r"[^A-Za-z0-9._-]+", "_", ascii_value)
sanitized = re.sub(r"_+", "_", sanitized).strip("._-")
return sanitized.lower()
def _cleanup_shared_recipes(self) -> None:
for recipe_id in list(self._shared_recipes.keys()):
shared = self._shared_recipes.get(recipe_id)

View File

@@ -128,6 +128,49 @@ class ServiceRegistry:
async def get_civitai_client(cls):
"""Get or create CivitAI client instance"""
service_name = "civitai_client"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .civitai_client import CivitaiClient
client = await CivitaiClient.get_instance()
cls._services[service_name] = client
logger.debug(f"Created and registered {service_name}")
return client
@classmethod
async def get_model_update_service(cls):
"""Get or create the model update tracking service."""
service_name = "model_update_service"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
if service_name in cls._services:
return cls._services[service_name]
from .model_update_service import ModelUpdateService
from .persistent_model_cache import get_persistent_cache
cache = get_persistent_cache()
service = ModelUpdateService(cache.get_database_path())
cls._services[service_name] = service
logger.debug(f"Created and registered {service_name}")
return service
@classmethod
async def get_civarchive_client(cls):
"""Get or create CivArchive client instance"""
service_name = "civarchive_client"
if service_name in cls._services:
return cls._services[service_name]
@@ -138,9 +181,9 @@ class ServiceRegistry:
return cls._services[service_name]
# Import here to avoid circular imports
from .civitai_client import CivitaiClient
from .civarchive_client import CivArchiveClient
client = await CivitaiClient.get_instance()
client = await CivArchiveClient.get_instance()
cls._services[service_name] = client
logger.debug(f"Created and registered {service_name}")
return client

View File

@@ -1,17 +1,34 @@
import asyncio
import copy
import json
import os
import logging
from pathlib import Path
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, Optional
from threading import Lock
from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from ..utils.constants import DEFAULT_PRIORITY_TAG_CONFIG
from ..utils.settings_paths import ensure_settings_file
from ..utils.tag_priorities import (
PriorityTagEntry,
collect_canonical_tags,
parse_priority_tag_string,
resolve_priority_tag,
)
logger = logging.getLogger(__name__)
CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
"civitai_api_key",
"folder_paths",
)
DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "",
"use_portable_settings": False,
"language": "en",
"show_only_sfw": False,
"enable_metadata_archive_db": False,
@@ -26,6 +43,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"default_embedding_root": "",
"base_model_path_mappings": {},
"download_path_templates": {},
"folder_paths": {},
"example_images_path": "",
"optimize_example_images": True,
"auto_download_example_images": False,
@@ -33,14 +51,28 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"autoplay_on_hover": False,
"display_density": "default",
"card_info_display": "always",
"show_folder_sidebar": True,
"include_trigger_words": False,
"compact_mode": False,
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
"model_name_display": "model_name",
"model_card_footer_action": "example_images",
}
class SettingsManager:
def __init__(self):
self.settings_file = ensure_settings_file(logger)
self._standalone_mode = self._detect_standalone_mode()
self._startup_messages: List[Dict[str, Any]] = []
self._needs_initial_save = False
self._bootstrap_reason: Optional[str] = None
self._seed_template: Optional[Dict[str, Any]] = None
self._template_payload_cache: Optional[Dict[str, Any]] = None
self._template_payload_cache_loaded = False
self._original_disk_payload: Optional[Dict[str, Any]] = None
self._preserve_disk_template = False
self._template_path = Path(__file__).resolve().parents[2] / "settings.json.example"
self.settings = self._load_settings()
self._migrate_setting_keys()
self._ensure_default_settings()
@@ -48,39 +80,206 @@ class SettingsManager:
self._migrate_download_path_template()
self._auto_set_default_roots()
self._check_environment_variables()
self._collect_configuration_warnings()
if self._needs_initial_save:
self._save_settings()
self._needs_initial_save = False
def _detect_standalone_mode(self) -> bool:
"""Return ``True`` when running in standalone mode."""
return os.environ.get("LORA_MANAGER_STANDALONE") == "1"
def _load_settings(self) -> Dict[str, Any]:
"""Load settings from file"""
if os.path.exists(self.settings_file):
try:
with open(self.settings_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading settings: {e}")
data = json.load(f)
if isinstance(data, dict):
self._original_disk_payload = copy.deepcopy(data)
if self._matches_template_payload(data):
self._preserve_disk_template = True
return data
except json.JSONDecodeError as exc:
logger.error("Failed to parse settings.json: %s", exc)
self._add_startup_message(
code="settings-json-invalid",
title="Settings file could not be parsed",
message=(
"LoRA Manager could not parse settings.json. Default settings "
"will be used for this session."
),
severity="error",
actions=self._default_settings_actions(),
details=str(exc),
dismissible=False,
)
self._needs_initial_save = True
self._bootstrap_reason = "invalid"
except Exception as exc: # pragma: no cover - defensive guard
logger.error("Unexpected error loading settings: %s", exc)
self._add_startup_message(
code="settings-json-unreadable",
title="Settings file could not be read",
message="LoRA Manager could not read settings.json. Default settings will be used for this session.",
severity="error",
actions=self._default_settings_actions(),
details=str(exc),
dismissible=False,
)
self._needs_initial_save = True
self._bootstrap_reason = "unreadable"
if not os.path.exists(self.settings_file):
self._needs_initial_save = True
self._bootstrap_reason = "missing"
seeded = self._load_settings_template()
if seeded is not None:
defaults = self._get_default_settings()
merged = self._merge_template_with_defaults(defaults, seeded)
return merged
return self._get_default_settings()
def _load_settings_template(self) -> Optional[Dict[str, Any]]:
"""Load the bundled template when no user settings are found."""
payload = self._read_template_payload()
if payload is None:
return None
self._seed_template = copy.deepcopy(payload)
return copy.deepcopy(payload)
def _read_template_payload(self) -> Optional[Dict[str, Any]]:
"""Return the cached contents of ``settings.json.example`` when available."""
if self._template_payload_cache_loaded:
if self._template_payload_cache is None:
return None
return copy.deepcopy(self._template_payload_cache)
self._template_payload_cache_loaded = True
try:
with self._template_path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
logger.debug("settings.json.example not found at %s", self._template_path)
return None
except json.JSONDecodeError as exc:
logger.warning("Failed to parse settings.json.example: %s", exc)
return None
if not isinstance(data, dict):
logger.debug("settings.json.example is not a JSON object; ignoring template")
return None
self._template_payload_cache = copy.deepcopy(data)
return copy.deepcopy(self._template_payload_cache)
def _matches_template_payload(self, payload: Mapping[str, Any]) -> bool:
"""Return ``True`` when ``payload`` matches the bundled template."""
template = self._read_template_payload()
if template is None:
return False
return payload == template
def _merge_template_with_defaults(
self, defaults: Dict[str, Any], template: Mapping[str, Any]
) -> Dict[str, Any]:
"""Merge template values into the in-memory defaults."""
merged = copy.deepcopy(defaults)
for key, value in template.items():
if key == "folder_paths" and isinstance(value, Mapping):
merged[key] = self._normalize_folder_paths(value)
else:
merged[key] = copy.deepcopy(value)
merged.setdefault("language", "en")
merged.setdefault("folder_paths", {})
library_name = merged.get("active_library") or "default"
merged["libraries"] = {
library_name: self._build_library_payload(
folder_paths=merged.get("folder_paths", {}),
default_lora_root=merged.get("default_lora_root"),
default_checkpoint_root=merged.get("default_checkpoint_root"),
default_embedding_root=merged.get("default_embedding_root"),
)
}
merged["active_library"] = library_name
return merged
def _ensure_default_settings(self) -> None:
"""Ensure all default settings keys exist"""
updated = False
for key, value in self._get_default_settings().items():
defaults = self._get_default_settings()
updated_existing = False
inserted_defaults = False
if "priority_tags" in self.settings:
normalized_priority = self._normalize_priority_tag_config(
self.settings.get("priority_tags")
)
if normalized_priority != self.settings.get("priority_tags"):
self.settings["priority_tags"] = normalized_priority
updated_existing = True
else:
self.settings["priority_tags"] = copy.deepcopy(
defaults.get("priority_tags", DEFAULT_PRIORITY_TAG_CONFIG)
)
inserted_defaults = True
for key, value in defaults.items():
if key == "priority_tags":
continue
if key not in self.settings:
if isinstance(value, dict):
self.settings[key] = value.copy()
self.settings[key] = copy.deepcopy(value)
else:
self.settings[key] = value
updated = True
if updated:
inserted_defaults = True
if updated_existing or (
inserted_defaults and self._bootstrap_reason in {"invalid", "unreadable"}
):
self._save_settings()
def _migrate_to_library_registry(self) -> None:
"""Ensure settings include the multi-library registry structure."""
libraries = self.settings.get("libraries")
active_name = self.settings.get("active_library")
initial_bootstrap = self._bootstrap_reason == "missing"
if not isinstance(libraries, dict) or not libraries:
raw_top_level_paths = self.settings.get("folder_paths", {})
normalized_top_level_paths: Dict[str, List[str]] = {}
if isinstance(raw_top_level_paths, Mapping):
normalized_top_level_paths = self._normalize_folder_paths(raw_top_level_paths)
if normalized_top_level_paths != raw_top_level_paths:
self.settings["folder_paths"] = copy.deepcopy(normalized_top_level_paths)
top_level_has_paths = self._has_configured_paths(normalized_top_level_paths)
needs_library_bootstrap = not isinstance(libraries, dict) or not libraries
if (
not needs_library_bootstrap
and top_level_has_paths
and len(libraries) == 1
):
only_library_payload = next(iter(libraries.values()))
if isinstance(only_library_payload, Mapping):
folder_payload = only_library_payload.get("folder_paths")
if not self._has_configured_paths(folder_payload):
needs_library_bootstrap = True
if needs_library_bootstrap:
library_name = active_name or "default"
library_payload = self._build_library_payload(
folder_paths=self.settings.get("folder_paths", {}),
folder_paths=normalized_top_level_paths,
default_lora_root=self.settings.get("default_lora_root", ""),
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""),
@@ -89,17 +288,40 @@ class SettingsManager:
self.settings["libraries"] = libraries
self.settings["active_library"] = library_name
self._sync_active_library_to_root(save=False)
self._save_settings()
if not initial_bootstrap and not self._preserve_disk_template:
self._save_settings()
return
seed_library_name: Optional[str] = None
if top_level_has_paths and isinstance(libraries, dict):
target_name: Optional[str] = None
if active_name and active_name in libraries:
target_name = active_name
elif len(libraries) == 1:
target_name = next(iter(libraries.keys()))
if target_name:
candidate_payload = libraries.get(target_name)
if isinstance(candidate_payload, Mapping) and not self._has_configured_paths(candidate_payload.get("folder_paths")):
seed_library_name = target_name
sanitized_libraries: Dict[str, Dict[str, Any]] = {}
changed = False
for name, data in libraries.items():
if not isinstance(data, dict):
data = {}
changed = True
candidate_folder_paths = data.get("folder_paths")
if (
seed_library_name == name
and not self._has_configured_paths(candidate_folder_paths)
and top_level_has_paths
):
candidate_folder_paths = normalized_top_level_paths
payload = self._build_library_payload(
folder_paths=data.get("folder_paths"),
folder_paths=candidate_folder_paths,
default_lora_root=data.get("default_lora_root"),
default_checkpoint_root=data.get("default_checkpoint_root"),
default_embedding_root=data.get("default_embedding_root"),
@@ -114,12 +336,15 @@ class SettingsManager:
self.settings["libraries"] = sanitized_libraries
if not active_name or active_name not in sanitized_libraries:
changed = True
if sanitized_libraries:
self.settings["active_library"] = next(iter(sanitized_libraries.keys()))
else:
self.settings["active_library"] = "default"
self._sync_active_library_to_root(save=changed)
self._sync_active_library_to_root(save=changed and not initial_bootstrap)
if changed and initial_bootstrap:
self._needs_initial_save = True
def _sync_active_library_to_root(self, *, save: bool = False) -> None:
"""Update top-level folder path settings to mirror the active library."""
@@ -208,6 +433,25 @@ class SettingsManager:
normalized[key] = cleaned
return normalized
def _has_configured_paths(self, folder_paths: Any) -> bool:
if not isinstance(folder_paths, Mapping):
return False
for values in folder_paths.values():
if isinstance(values, str):
candidate_values = [values]
else:
try:
candidate_values = list(values) # type: ignore[arg-type]
except TypeError:
continue
for path in candidate_values:
if isinstance(path, str) and path.strip():
return True
return False
def _validate_folder_paths(
self,
library_name: str,
@@ -300,6 +544,7 @@ class SettingsManager:
'cardInfoDisplay': 'card_info_display',
'includeTriggerWords': 'include_trigger_words',
'compactMode': 'compact_mode',
'modelCardFooterAction': 'model_card_footer_action',
}
updated = False
@@ -363,7 +608,10 @@ class SettingsManager:
default_checkpoint_root=self.settings.get('default_checkpoint_root'),
default_embedding_root=self.settings.get('default_embedding_root'),
)
self._save_settings()
if self._bootstrap_reason == "missing":
self._needs_initial_save = True
else:
self._save_settings()
def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed"""
@@ -374,18 +622,159 @@ class SettingsManager:
self.settings['civitai_api_key'] = env_api_key
self._save_settings()
def _default_settings_actions(self) -> List[Dict[str, Any]]:
return [
{
"action": "open-settings-location",
"label": "Open settings folder",
"type": "primary",
"icon": "fas fa-folder-open",
}
]
def _add_startup_message(
self,
*,
code: str,
title: str,
message: str,
severity: str = "info",
actions: Optional[List[Dict[str, Any]]] = None,
details: Optional[str] = None,
dismissible: bool = False,
) -> None:
if any(existing.get("code") == code for existing in self._startup_messages):
return
payload: Dict[str, Any] = {
"code": code,
"title": title,
"message": message,
"severity": severity.lower(),
"dismissible": bool(dismissible),
}
if actions:
payload["actions"] = [dict(action) for action in actions]
if details:
payload["details"] = details
payload["settings_file"] = self.settings_file
self._startup_messages.append(payload)
def _collect_configuration_warnings(self) -> None:
if not self._standalone_mode:
return
folder_paths = self.settings.get('folder_paths', {}) or {}
monitored_keys = ('loras', 'checkpoints', 'embeddings')
has_valid_paths = False
for key in monitored_keys:
raw_paths = folder_paths.get(key) or []
if isinstance(raw_paths, str):
raw_paths = [raw_paths]
try:
iterator = list(raw_paths)
except TypeError:
continue
if any(isinstance(path, str) and path and os.path.exists(path) for path in iterator):
has_valid_paths = True
break
if not has_valid_paths:
if self._bootstrap_reason == "missing":
message = (
"LoRA Manager created a default settings.json because no configuration was found. "
"Edit settings.json to add your model directories so library scanning can run."
)
else:
message = (
"LoRA Manager could not locate any configured model directories. "
"Edit settings.json to add your model folders so library scanning can run."
)
self._add_startup_message(
code="missing-model-paths",
title="Model folders need setup",
message=message,
severity="warning",
actions=self._default_settings_actions(),
dismissible=False,
)
def refresh_environment_variables(self) -> None:
"""Refresh settings from environment variables"""
self._check_environment_variables()
def _get_default_settings(self) -> Dict[str, Any]:
"""Return default settings"""
defaults = DEFAULT_SETTINGS.copy()
# Ensure nested dicts are independent copies
defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults['base_model_path_mappings'] = {}
defaults['download_path_templates'] = {}
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault('folder_paths', {})
library_name = defaults.get("active_library") or "default"
default_library = self._build_library_payload(
folder_paths=defaults.get("folder_paths", {}),
default_lora_root=defaults.get("default_lora_root"),
default_checkpoint_root=defaults.get("default_checkpoint_root"),
default_embedding_root=defaults.get("default_embedding_root"),
)
defaults['libraries'] = {library_name: default_library}
defaults['active_library'] = library_name
return defaults
def _normalize_priority_tag_config(self, value: Any) -> Dict[str, str]:
normalized: Dict[str, str] = {}
if isinstance(value, Mapping):
for key, raw in value.items():
if not isinstance(key, str) or not isinstance(raw, str):
continue
normalized[key] = raw.strip()
for model_type, default_value in DEFAULT_PRIORITY_TAG_CONFIG.items():
normalized.setdefault(model_type, default_value)
return normalized
def get_priority_tag_config(self) -> Dict[str, str]:
stored_value = self.settings.get("priority_tags")
normalized = self._normalize_priority_tag_config(stored_value)
if normalized != stored_value:
self.settings["priority_tags"] = normalized
self._save_settings()
return normalized.copy()
def get_startup_messages(self) -> List[Dict[str, Any]]:
return [message.copy() for message in self._startup_messages]
def get_priority_tag_entries(self, model_type: str) -> List[PriorityTagEntry]:
config = self.get_priority_tag_config()
raw_config = config.get(model_type, "")
return parse_priority_tag_string(raw_config)
def resolve_priority_tag_for_model(
self, tags: Sequence[str] | Iterable[str], model_type: str
) -> str:
entries = self.get_priority_tag_entries(model_type)
resolved = resolve_priority_tag(tags, entries)
if resolved:
return resolved
for tag in tags:
if isinstance(tag, str) and tag:
return tag
return ""
def get_priority_tag_suggestions(self) -> Dict[str, List[str]]:
suggestions: Dict[str, List[str]] = {}
config = self.get_priority_tag_config()
for model_type, raw_value in config.items():
entries = parse_priority_tag_string(raw_value)
suggestions[model_type] = collect_canonical_tags(entries)
return suggestions
def get(self, key: str, default: Any = None) -> Any:
"""Get setting value"""
return self.settings.get(key, default)
@@ -401,6 +790,8 @@ class SettingsManager:
self._update_active_library_entry(default_checkpoint_root=str(value))
elif key == 'default_embedding_root':
self._update_active_library_entry(default_embedding_root=str(value))
elif key == 'model_name_display':
self._notify_model_name_display_change(value)
self._save_settings()
def delete(self, key: str) -> None:
@@ -410,13 +801,110 @@ class SettingsManager:
self._save_settings()
logger.info(f"Deleted setting: {key}")
def _notify_model_name_display_change(self, value: Any) -> None:
"""Trigger cache resorting when the model name display preference updates."""
try:
from .service_registry import ServiceRegistry # type: ignore
except Exception: # pragma: no cover - registry optional in some contexts
return
display_mode = value if isinstance(value, str) else "model_name"
pending: List[Tuple[Optional[asyncio.AbstractEventLoop], Awaitable[Any]]] = []
def _resolve_service_loop(service: Any) -> Optional[asyncio.AbstractEventLoop]:
loop = getattr(service, "loop", None)
if loop is None:
loop = getattr(service, "_loop", None)
return loop if isinstance(loop, asyncio.AbstractEventLoop) else None
for service_name in (
"lora_scanner",
"checkpoint_scanner",
"embedding_scanner",
"recipe_scanner",
):
service = ServiceRegistry.get_service_sync(service_name)
if not service or not hasattr(service, "on_model_name_display_changed"):
continue
try:
result = service.on_model_name_display_changed(display_mode)
except Exception as exc: # pragma: no cover - defensive guard
logger.debug(
"Service %s failed to schedule name display update: %s",
service_name,
exc,
)
continue
if asyncio.iscoroutine(result):
service_loop = _resolve_service_loop(service)
pending.append((service_loop, result))
if not pending:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
for service_loop, coroutine in pending:
target_loop = service_loop or loop
if target_loop is None:
try:
asyncio.run(coroutine)
except RuntimeError:
logger.debug("Skipping name display update due to missing event loop")
continue
if loop is not None and target_loop is loop:
target_loop.create_task(coroutine)
continue
if target_loop.is_running():
try:
asyncio.run_coroutine_threadsafe(coroutine, target_loop)
except Exception as exc: # pragma: no cover - defensive guard
logger.debug("Failed to dispatch name display update: %s", exc)
continue
try:
asyncio.run(coroutine)
except RuntimeError:
logger.debug("Skipping name display update due to closed loop")
def _save_settings(self) -> None:
"""Save settings to file"""
try:
payload = self._serialize_settings_for_disk()
with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self.settings, f, indent=2)
json.dump(payload, f, indent=2)
except Exception as e:
logger.error(f"Error saving settings: {e}")
else:
if self._bootstrap_reason == "missing":
self._bootstrap_reason = None
self._seed_template = None
def _serialize_settings_for_disk(self) -> Dict[str, Any]:
"""Return the settings payload that should be persisted to disk."""
if self._bootstrap_reason == "missing":
minimal: Dict[str, Any] = {}
for key in CORE_USER_SETTING_KEYS:
if key in self.settings:
minimal[key] = copy.deepcopy(self.settings[key])
if self._seed_template:
for key, value in self._seed_template.items():
minimal.setdefault(key, copy.deepcopy(value))
return minimal
return copy.deepcopy(self.settings)
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
"""Return a copy of the registered libraries."""
@@ -688,4 +1176,38 @@ class SettingsManager:
return templates.get(model_type, '{base_model}/{first_tag}')
settings = SettingsManager()
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
_SETTINGS_MANAGER_LOCK = Lock()
# Legacy module-level alias for backwards compatibility with callers that
# monkeypatch ``py.services.settings_manager.settings`` during tests.
settings: Optional["SettingsManager"] = None
def get_settings_manager() -> "SettingsManager":
"""Return the lazily initialised global :class:`SettingsManager`."""
global _SETTINGS_MANAGER, settings
if settings is not None:
return settings
if _SETTINGS_MANAGER is None:
with _SETTINGS_MANAGER_LOCK:
if _SETTINGS_MANAGER is None:
_SETTINGS_MANAGER = SettingsManager()
settings = _SETTINGS_MANAGER
return _SETTINGS_MANAGER
def reset_settings_manager() -> None:
"""Reset the cached settings manager instance.
Primarily intended for tests so they can configure the settings
directory before the manager touches the filesystem.
"""
global _SETTINGS_MANAGER, settings
with _SETTINGS_MANAGER_LOCK:
_SETTINGS_MANAGER = None
settings = None

View File

@@ -6,6 +6,7 @@ import logging
from typing import Any, Dict, Optional, Protocol, Sequence
from ..metadata_sync_service import MetadataSyncService
from ...utils.metadata_manager import MetadataManager
class MetadataRefreshProgressReporter(Protocol):
@@ -70,6 +71,7 @@ class BulkMetadataRefreshUseCase:
for model in to_process:
try:
original_name = model.get("model_name")
await MetadataManager.hydrate_model_data(model)
result, _ = await self._metadata_sync.fetch_and_update_model(
sha256=model["sha256"],
file_path=model["file_path"],

View File

@@ -155,11 +155,21 @@ class WebSocketManager:
async def broadcast_download_progress(self, download_id: str, data: Dict):
"""Send progress update to specific download client"""
# Store simplified progress data in memory (only progress percentage)
self._download_progress[download_id] = {
progress_entry = {
'progress': data.get('progress', 0),
'timestamp': datetime.now()
'timestamp': datetime.now(),
}
for field in ('bytes_downloaded', 'total_bytes', 'bytes_per_second'):
if field in data:
progress_entry[field] = data[field]
if 'status' in data:
progress_entry['status'] = data['status']
if 'message' in data:
progress_entry['message'] = data['message']
self._download_progress[download_id] = progress_entry
if download_id not in self._download_websockets:
logger.debug(f"No WebSocket found for download ID: {download_id}")

47
py/utils/civitai_utils.py Normal file
View File

@@ -0,0 +1,47 @@
"""Utilities for working with Civitai assets."""
from __future__ import annotations
from urllib.parse import urlparse, urlunparse
def rewrite_preview_url(source_url: str | None, media_type: str | None = None) -> tuple[str | None, bool]:
"""Rewrite Civitai preview URLs to use optimized renditions.
Args:
source_url: Original preview URL from the Civitai API.
media_type: Optional media type hint (e.g. ``"image"`` or ``"video"``).
Returns:
A tuple of the potentially rewritten URL and a flag indicating whether the
replacement occurred. When the URL is not rewritten, the original value is
returned with ``False``.
"""
if not source_url:
return source_url, False
try:
parsed = urlparse(source_url)
except ValueError:
return source_url, False
if parsed.netloc.lower() != "image.civitai.com":
return source_url, False
replacement = "/width=450,optimized=true"
if (media_type or "").lower() == "video":
replacement = "/transcode=true,width=450,optimized=true"
if "/original=true" not in parsed.path:
return source_url, False
updated_path = parsed.path.replace("/original=true", replacement, 1)
if updated_path == parsed.path:
return source_url, False
rewritten = urlunparse(parsed._replace(path=updated_path))
return rewritten, True
__all__ = ["rewrite_preview_url"]

View File

@@ -48,6 +48,13 @@ SUPPORTED_MEDIA_EXTENSIONS = {
# Valid Lora types
VALID_LORA_TYPES = ['lora', 'locon', 'dora']
# Supported Civitai model types for user model queries (case-insensitive)
CIVITAI_USER_MODEL_TYPES = [
*VALID_LORA_TYPES,
'textualinversion',
'checkpoint',
]
# Auto-organize settings
AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming the system
@@ -57,4 +64,11 @@ CIVITAI_MODEL_TAGS = [
'realistic', 'anime', 'toon', 'furry', 'style',
'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action'
]
]
# Default priority tag configuration strings for each model type
DEFAULT_PRIORITY_TAG_CONFIG = {
'lora': ', '.join(CIVITAI_MODEL_TAGS),
'checkpoint': ', '.join(CIVITAI_MODEL_TAGS),
'embedding': ', '.join(CIVITAI_MODEL_TAGS),
}

View File

@@ -5,8 +5,10 @@ import json
import time
import logging
import os
import re
import shutil
from typing import Any, Dict
import uuid
from typing import Any, Dict, Iterable, List, Set, Tuple
from ..services.service_registry import ServiceRegistry
from ..utils.example_images_paths import (
@@ -18,7 +20,7 @@ from ..utils.metadata_manager import MetadataManager
from .example_images_processor import ExampleImagesProcessor
from .example_images_metadata import MetadataUpdater
from ..services.downloader import get_downloader
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
class ExampleImagesDownloadError(RuntimeError):
@@ -105,9 +107,10 @@ class DownloadManager:
self._progress = _DownloadProgress()
self._ws_manager = ws_manager
self._state_lock = state_lock or asyncio.Lock()
self._stop_requested = False
def _resolve_output_dir(self, library_name: str | None = None) -> str:
base_path = settings.get('example_images_path')
base_path = get_settings_manager().get('example_images_path')
if not base_path:
return ''
return ensure_library_root_exists(library_name)
@@ -126,7 +129,8 @@ class DownloadManager:
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2))
base_path = settings.get('example_images_path')
settings_manager = get_settings_manager()
base_path = settings_manager.get('example_images_path')
if not base_path:
error_msg = 'Example images path not configured in settings'
@@ -138,12 +142,13 @@ class DownloadManager:
}
raise DownloadConfigurationError(error_msg)
active_library = settings.get_active_library_name()
active_library = get_settings_manager().get_active_library_name()
output_dir = self._resolve_output_dir(active_library)
if not output_dir:
raise DownloadConfigurationError('Example images path not configured in settings')
self._progress.reset()
self._stop_requested = False
self._progress['status'] = 'running'
self._progress['start_time'] = time.time()
self._progress['end_time'] = None
@@ -151,7 +156,7 @@ class DownloadManager:
progress_file = os.path.join(output_dir, '.download_progress.json')
progress_source = progress_file
if uses_library_scoped_folders():
legacy_root = settings.get('example_images_path') or ''
legacy_root = get_settings_manager().get('example_images_path') or ''
legacy_progress = os.path.join(legacy_root, '.download_progress.json') if legacy_root else ''
if legacy_progress and os.path.exists(legacy_progress) and not os.path.exists(progress_file):
try:
@@ -266,6 +271,27 @@ class DownloadManager:
'success': True,
'message': 'Download resumed'
}
async def stop_download(self, request):
"""Stop the example images download after the current model completes."""
async with self._state_lock:
if not self._is_downloading:
raise DownloadNotRunningError()
if self._progress['status'] in {'completed', 'error', 'stopped'}:
raise DownloadNotRunningError()
if self._progress['status'] != 'stopping':
self._stop_requested = True
self._progress['status'] = 'stopping'
await self._broadcast_progress(status='stopping')
return {
'success': True,
'message': 'Download stopping'
}
async def _download_all_example_images(
self,
@@ -310,6 +336,12 @@ class DownloadManager:
# Process each model
for i, (scanner_type, model, scanner) in enumerate(all_models):
async with self._state_lock:
current_status = self._progress['status']
if current_status not in {'running', 'paused', 'stopping'}:
break
# Main logic for processing model is here, but actual operations are delegated to other classes
was_remote_download = await self._process_model(
scanner_type,
@@ -320,24 +352,59 @@ class DownloadManager:
downloader,
library_name,
)
# Update progress
self._progress['completed'] += 1
await self._broadcast_progress(status='running')
async with self._state_lock:
current_status = self._progress['status']
should_stop = self._stop_requested and current_status == 'stopping'
broadcast_status = 'running' if current_status == 'running' else current_status
await self._broadcast_progress(status=broadcast_status)
if should_stop:
break
# Only add delay after remote download of models, and not after processing the last model
if was_remote_download and i < len(all_models) - 1 and self._progress['status'] == 'running':
if (
was_remote_download
and i < len(all_models) - 1
and current_status == 'running'
):
await asyncio.sleep(delay)
# Mark as completed
self._progress['status'] = 'completed'
self._progress['end_time'] = time.time()
logger.debug(
"Example images download completed: %s/%s models processed",
self._progress['completed'],
self._progress['total'],
)
await self._broadcast_progress(status='completed')
async with self._state_lock:
if self._stop_requested and self._progress['status'] == 'stopping':
self._progress['status'] = 'stopped'
self._progress['end_time'] = time.time()
self._stop_requested = False
final_status = 'stopped'
elif self._progress['status'] not in {'error', 'stopped'}:
self._progress['status'] = 'completed'
self._progress['end_time'] = time.time()
self._stop_requested = False
final_status = 'completed'
else:
final_status = self._progress['status']
self._stop_requested = False
if self._progress['end_time'] is None:
self._progress['end_time'] = time.time()
if final_status == 'completed':
logger.debug(
"Example images download completed: %s/%s models processed",
self._progress['completed'],
self._progress['total'],
)
elif final_status == 'stopped':
logger.debug(
"Example images download stopped: %s/%s models processed",
self._progress['completed'],
self._progress['total'],
)
await self._broadcast_progress(status=final_status)
except Exception as e:
error_msg = f"Error during example images download: {str(e)}"
@@ -359,6 +426,7 @@ class DownloadManager:
async with self._state_lock:
self._is_downloading = False
self._download_task = None
self._stop_requested = False
async def _process_model(
self,
@@ -377,7 +445,7 @@ class DownloadManager:
await asyncio.sleep(1)
# Check if download should continue
if self._progress['status'] != 'running':
if self._progress['status'] not in {'running', 'stopping'}:
logger.info(f"Download stopped: {self._progress['status']}")
return False # Return False to indicate no remote download happened
@@ -450,10 +518,12 @@ class DownloadManager:
if civitai_payload.get('images'):
images = civitai_payload.get('images', [])
success, is_stale = await ExampleImagesProcessor.download_model_images(
success, is_stale, failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, images, model_dir, optimize, downloader
)
failed_urls: Set[str] = set(failed_images)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in self._progress['refreshed_models']:
await MetadataUpdater.refresh_model_metadata(
@@ -470,20 +540,36 @@ class DownloadManager:
if updated_civitai.get('images'):
# Retry download with updated metadata
updated_images = updated_civitai.get('images', [])
success, _ = await ExampleImagesProcessor.download_model_images(
success, _, additional_failed = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, updated_images, model_dir, optimize, downloader
)
failed_urls.update(additional_failed)
self._progress['refreshed_models'].add(model_hash)
# Mark as processed if successful, or as failed if unsuccessful after refresh
if success:
if failed_urls:
await self._remove_failed_images_from_metadata(
model_hash,
model_name,
model_dir,
failed_urls,
scanner,
)
if failed_urls:
self._progress['failed_models'].add(model_hash)
self._progress['processed_models'].add(model_hash)
logger.info(
"Removed %s failed example images for %s", len(failed_urls), model_name
)
elif success:
self._progress['processed_models'].add(model_hash)
else:
# If we refreshed metadata and still failed, mark as permanently failed
if model_hash in self._progress['refreshed_models']:
self._progress['failed_models'].add(model_hash)
logger.info(f"Marking model {model_name} as failed after metadata refresh")
self._progress['failed_models'].add(model_hash)
logger.info(
"Example images download failed for %s despite metadata refresh", model_name
)
return True # Return True to indicate a remote download happened
else:
@@ -555,16 +641,18 @@ class DownloadManager:
if not model_hashes:
raise DownloadConfigurationError('Missing model_hashes parameter')
base_path = settings.get('example_images_path')
settings_manager = get_settings_manager()
base_path = settings_manager.get('example_images_path')
if not base_path:
raise DownloadConfigurationError('Example images path not configured in settings')
active_library = settings.get_active_library_name()
active_library = settings_manager.get_active_library_name()
output_dir = self._resolve_output_dir(active_library)
if not output_dir:
raise DownloadConfigurationError('Example images path not configured in settings')
self._progress.reset()
self._stop_requested = False
self._progress['total'] = len(model_hashes)
self._progress['status'] = 'running'
self._progress['start_time'] = time.time()
@@ -586,10 +674,15 @@ class DownloadManager:
async with self._state_lock:
self._is_downloading = False
final_status = self._progress['status']
message = 'Force download completed'
if final_status == 'stopped':
message = 'Force download stopped'
return {
'success': True,
'message': 'Force download completed',
'message': message,
'result': result
}
@@ -647,6 +740,12 @@ class DownloadManager:
# Process each model
success_count = 0
for i, (scanner_type, model, scanner) in enumerate(models_to_process):
async with self._state_lock:
current_status = self._progress['status']
if current_status not in {'running', 'paused', 'stopping'}:
break
# Force process this model regardless of previous status
was_successful = await self._process_specific_model(
scanner_type,
@@ -657,32 +756,65 @@ class DownloadManager:
downloader,
library_name,
)
if was_successful:
success_count += 1
# Update progress
self._progress['completed'] += 1
async with self._state_lock:
current_status = self._progress['status']
should_stop = self._stop_requested and current_status == 'stopping'
broadcast_status = 'running' if current_status == 'running' else current_status
# Send progress update via WebSocket
await self._broadcast_progress(status='running')
await self._broadcast_progress(status=broadcast_status)
if should_stop:
break
# Only add delay after remote download, and not after processing the last model
if was_successful and i < len(models_to_process) - 1 and self._progress['status'] == 'running':
if (
was_successful
and i < len(models_to_process) - 1
and current_status == 'running'
):
await asyncio.sleep(delay)
# Mark as completed
self._progress['status'] = 'completed'
self._progress['end_time'] = time.time()
logger.debug(
"Forced example images download completed: %s/%s models processed",
self._progress['completed'],
self._progress['total'],
)
async with self._state_lock:
if self._stop_requested and self._progress['status'] == 'stopping':
self._progress['status'] = 'stopped'
self._progress['end_time'] = time.time()
self._stop_requested = False
final_status = 'stopped'
elif self._progress['status'] not in {'error', 'stopped'}:
self._progress['status'] = 'completed'
self._progress['end_time'] = time.time()
self._stop_requested = False
final_status = 'completed'
else:
final_status = self._progress['status']
self._stop_requested = False
if self._progress['end_time'] is None:
self._progress['end_time'] = time.time()
if final_status == 'completed':
logger.debug(
"Forced example images download completed: %s/%s models processed",
self._progress['completed'],
self._progress['total'],
)
elif final_status == 'stopped':
logger.debug(
"Forced example images download stopped: %s/%s models processed",
self._progress['completed'],
self._progress['total'],
)
# Send final progress via WebSocket
await self._broadcast_progress(status='completed')
await self._broadcast_progress(status=final_status)
return {
'total': self._progress['total'],
'processed': self._progress['completed'],
@@ -724,7 +856,7 @@ class DownloadManager:
await asyncio.sleep(1)
# Check if download should continue
if self._progress['status'] != 'running':
if self._progress['status'] not in {'running', 'stopping'}:
logger.info(f"Download stopped: {self._progress['status']}")
return False
@@ -776,6 +908,8 @@ class DownloadManager:
model_hash, model_name, images, model_dir, optimize, downloader
)
failed_urls: Set[str] = set(failed_images)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in self._progress['refreshed_models']:
await MetadataUpdater.refresh_model_metadata(
@@ -797,19 +931,18 @@ class DownloadManager:
)
# Combine failed images from both attempts
failed_images.extend(additional_failed_images)
failed_urls.update(additional_failed_images)
self._progress['refreshed_models'].add(model_hash)
# For forced downloads, remove failed images from metadata
if failed_images:
# Create a copy of images excluding failed ones
if failed_urls:
await self._remove_failed_images_from_metadata(
model_hash, model_name, failed_images, scanner
model_hash, model_name, model_dir, failed_urls, scanner
)
# Mark as processed
if success or failed_images: # Mark as processed if we successfully downloaded some images or removed failed ones
if success or failed_urls: # Mark as processed if we successfully downloaded some images or removed failed ones
self._progress['processed_models'].add(model_hash)
return True # Return True to indicate a remote download happened
@@ -826,49 +959,112 @@ class DownloadManager:
self._progress['last_error'] = error_msg
return False # Return False on exception
async def _remove_failed_images_from_metadata(self, model_hash, model_name, failed_images, scanner):
"""Remove failed images from model metadata"""
async def _remove_failed_images_from_metadata(
self,
model_hash: str,
model_name: str,
model_dir: str,
failed_images: Iterable[str],
scanner,
) -> None:
"""Mark failed images in model metadata so they won't be retried."""
failed_set: Set[str] = {url for url in failed_images if url}
if not failed_set:
return
try:
# Get current model data
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
if not model_data:
logger.warning(f"Could not find model data for {model_name} to remove failed images")
return
if not model_data.get('civitai', {}).get('images'):
civitai_payload = model_data.get('civitai') or {}
current_images = civitai_payload.get('images') or []
if not current_images:
logger.warning(f"No images in metadata for {model_name}")
return
# Get current images
current_images = model_data['civitai']['images']
# Filter out failed images
updated_images = [img for img in current_images if img.get('url') not in failed_images]
# If images were removed, update metadata
if len(updated_images) < len(current_images):
removed_count = len(current_images) - len(updated_images)
logger.info(f"Removing {removed_count} failed images from metadata for {model_name}")
# Update the images list
model_data['civitai']['images'] = updated_images
# Save metadata to file
file_path = model_data.get('file_path')
if file_path:
# Create a copy of model data without 'folder' field
model_copy = model_data.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.info(f"Saved updated metadata for {model_name} after removing failed images")
# Update the scanner cache
updated = False
for image in current_images:
image_url = image.get('url')
optimized_url = (
ExampleImagesProcessor.get_civitai_optimized_url(image_url)
if image_url and 'civitai.com' in image_url
else None
)
if image_url not in failed_set and optimized_url not in failed_set:
continue
if image.get('downloadFailed'):
continue
image['downloadFailed'] = True
image.setdefault('downloadError', 'not_found')
logger.debug(
"Marked example image %s for %s as failed due to missing remote asset",
image_url,
model_name,
)
updated = True
if not updated:
return
file_path = model_data.get('file_path')
if file_path:
model_copy = model_data.copy()
model_copy.pop('folder', None)
await MetadataManager.save_metadata(file_path, model_copy)
try:
await scanner.update_single_model_cache(file_path, file_path, model_data)
except Exception as e:
logger.error(f"Error removing failed images from metadata for {model_name}: {e}", exc_info=True)
except AttributeError:
logger.debug("Scanner does not expose cache update for %s", model_name)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error removing failed images from metadata for %s: %s", model_name, exc, exc_info=True
)
def _renumber_example_image_files(self, model_dir: str) -> None:
if not model_dir or not os.path.isdir(model_dir):
return
pattern = re.compile(r'^image_(\d+)(\.[^.]+)$', re.IGNORECASE)
matches: List[Tuple[int, str, str]] = []
for entry in os.listdir(model_dir):
match = pattern.match(entry)
if match:
matches.append((int(match.group(1)), entry, match.group(2)))
if not matches:
return
matches.sort(key=lambda item: item[0])
staged_paths: List[Tuple[str, str]] = []
for _, original_name, extension in matches:
source_path = os.path.join(model_dir, original_name)
temp_name = f"tmp_{uuid.uuid4().hex}_{original_name}"
temp_path = os.path.join(model_dir, temp_name)
try:
os.rename(source_path, temp_path)
staged_paths.append((temp_path, extension))
except OSError as exc:
logger.warning("Failed to stage rename for %s: %s", source_path, exc)
for new_index, (temp_path, extension) in enumerate(staged_paths):
final_name = f"image_{new_index}{extension}"
final_path = os.path.join(model_dir, final_name)
try:
os.rename(temp_path, final_path)
except OSError as exc:
logger.warning("Failed to finalise rename for %s: %s", final_path, exc)
async def _broadcast_progress(
self,

View File

@@ -3,7 +3,7 @@ import os
import sys
import subprocess
from aiohttp import web
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
from ..utils.example_images_paths import (
get_model_folder,
get_model_relative_path,
@@ -37,7 +37,8 @@ class ExampleImagesFileManager:
}, status=400)
# Get example images path from settings
example_images_path = settings.get('example_images_path')
settings_manager = get_settings_manager()
example_images_path = settings_manager.get('example_images_path')
if not example_images_path:
return web.json_response({
'success': False,
@@ -109,7 +110,8 @@ class ExampleImagesFileManager:
}, status=400)
# Get example images path from settings
example_images_path = settings.get('example_images_path')
settings_manager = get_settings_manager()
example_images_path = settings_manager.get('example_images_path')
if not example_images_path:
return web.json_response({
'success': False,
@@ -183,7 +185,8 @@ class ExampleImagesFileManager:
}, status=400)
# Get example images path from settings
example_images_path = settings.get('example_images_path')
settings_manager = get_settings_manager()
example_images_path = settings_manager.get('example_images_path')
if not example_images_path:
return web.json_response({
'has_images': False

View File

@@ -1,12 +1,13 @@
import logging
import os
import re
from typing import TYPE_CHECKING, Any, Dict, Optional
from ..recipes.constants import GEN_PARAM_KEYS
from ..services.metadata_service import get_default_metadata_provider, get_metadata_provider
from ..services.metadata_sync_service import MetadataSyncService
from ..services.preview_asset_service import PreviewAssetService
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
from ..services.downloader import get_downloader
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..utils.exif_utils import ExifUtils
@@ -20,13 +21,46 @@ _preview_service = PreviewAssetService(
exif_utils=ExifUtils,
)
_metadata_sync_service = MetadataSyncService(
metadata_manager=MetadataManager,
preview_service=_preview_service,
settings=settings,
default_metadata_provider_factory=get_default_metadata_provider,
metadata_provider_selector=get_metadata_provider,
)
_metadata_sync_service: MetadataSyncService | None = None
_metadata_sync_service_settings: Optional["SettingsManager"] = None
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..services.settings_manager import SettingsManager
def _build_metadata_sync_service(settings_manager: "SettingsManager") -> MetadataSyncService:
"""Construct a metadata sync service bound to the provided settings."""
return MetadataSyncService(
metadata_manager=MetadataManager,
preview_service=_preview_service,
settings=settings_manager,
default_metadata_provider_factory=get_default_metadata_provider,
metadata_provider_selector=get_metadata_provider,
)
def _get_metadata_sync_service() -> MetadataSyncService:
"""Return the shared metadata sync service, initialising it lazily."""
global _metadata_sync_service, _metadata_sync_service_settings
settings_manager = get_settings_manager()
if isinstance(_metadata_sync_service, MetadataSyncService):
if _metadata_sync_service_settings is not settings_manager:
_metadata_sync_service = _build_metadata_sync_service(settings_manager)
_metadata_sync_service_settings = settings_manager
elif _metadata_sync_service is None:
_metadata_sync_service = _build_metadata_sync_service(settings_manager)
_metadata_sync_service_settings = settings_manager
else:
# Tests may inject stand-ins that do not match the sync service type. Preserve
# those injections while still updating our cached settings reference so the
# next real service instantiation uses the current configuration.
_metadata_sync_service_settings = settings_manager
return _metadata_sync_service
class MetadataUpdater:
@@ -71,7 +105,8 @@ class MetadataUpdater:
async def update_cache_func(old_path, new_path, metadata):
return await scanner.update_single_model_cache(old_path, new_path, metadata)
success, error = await _metadata_sync_service.fetch_and_update_model(
await MetadataManager.hydrate_model_data(model_data)
success, error = await _get_metadata_sync_service().fetch_and_update_model(
sha256=model_hash,
file_path=file_path,
model_data=model_data,
@@ -151,16 +186,16 @@ class MetadataUpdater:
if is_supported:
local_images_paths.append(file_path)
await MetadataManager.hydrate_model_data(model)
civitai_data = model.setdefault('civitai', {})
# Check if metadata update is needed (no civitai field or empty images)
needs_update = not model.get('civitai') or not model.get('civitai', {}).get('images')
needs_update = not civitai_data or not civitai_data.get('images')
if needs_update and local_images_paths:
logger.debug(f"Found {len(local_images_paths)} local example images for {model.get('model_name')}, updating metadata")
# Create or get civitai field
if not model.get('civitai'):
model['civitai'] = {}
# Create images array
images = []
@@ -195,16 +230,13 @@ class MetadataUpdater:
images.append(image_entry)
# Update the model's civitai.images field
model['civitai']['images'] = images
civitai_data['images'] = images
# Save metadata to .metadata.json file
file_path = model.get('file_path')
try:
# Create a copy of model data without 'folder' field
model_copy = model.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.info(f"Saved metadata for {model.get('model_name')}")
except Exception as e:
@@ -237,16 +269,18 @@ class MetadataUpdater:
tuple: (regular_images, custom_images) - Both image arrays
"""
try:
# Ensure civitai field exists in model_data
if not model_data.get('civitai'):
model_data['civitai'] = {}
# Ensure customImages array exists
if not model_data['civitai'].get('customImages'):
model_data['civitai']['customImages'] = []
# Get current customImages array
custom_images = model_data['civitai']['customImages']
await MetadataManager.hydrate_model_data(model_data)
civitai_data = model_data.get('civitai')
if not isinstance(civitai_data, dict):
civitai_data = {}
model_data['civitai'] = civitai_data
custom_images = civitai_data.get('customImages')
if not isinstance(custom_images, list):
custom_images = []
civitai_data['customImages'] = custom_images
# Add new image entry for each imported file
for path_tuple in newly_imported_paths:
@@ -304,11 +338,8 @@ class MetadataUpdater:
file_path = model_data.get('file_path')
if file_path:
try:
# Create a copy of model data without 'folder' field
model_copy = model_data.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.info(f"Saved metadata for {model_data.get('model_name')}")
except Exception as e:
@@ -319,7 +350,7 @@ class MetadataUpdater:
await scanner.update_single_model_cache(file_path, file_path, model_data)
# Get regular images array (might be None)
regular_images = model_data['civitai'].get('images', [])
regular_images = civitai_data.get('images', [])
# Return both image arrays
return regular_images, custom_images
@@ -420,4 +451,4 @@ class MetadataUpdater:
except Exception as e:
logger.error(f"Error parsing image metadata: {e}", exc_info=True)
return None
return None

View File

@@ -3,7 +3,7 @@ import logging
import os
import re
import json
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
from ..services.service_registry import ServiceRegistry
from ..utils.example_images_paths import iter_library_roots
from ..utils.metadata_manager import MetadataManager
@@ -14,6 +14,25 @@ logger = logging.getLogger(__name__)
CURRENT_NAMING_VERSION = 2 # Increment this when naming conventions change
class _SettingsProxy:
def __init__(self):
self._manager = None
def _resolve(self):
if self._manager is None:
self._manager = get_settings_manager()
return self._manager
def get(self, *args, **kwargs):
return self._resolve().get(*args, **kwargs)
def __getattr__(self, item):
return getattr(self._resolve(), item)
settings = _SettingsProxy()
class ExampleImagesMigration:
"""Handles migrations for example images naming conventions"""

View File

@@ -8,7 +8,7 @@ import re
import shutil
from typing import Iterable, List, Optional, Tuple
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
_HEX_PATTERN = re.compile(r"[a-fA-F0-9]{64}")
@@ -18,7 +18,8 @@ logger = logging.getLogger(__name__)
def _get_configured_libraries() -> List[str]:
"""Return configured library names if multi-library support is enabled."""
libraries = settings.get("libraries")
settings_manager = get_settings_manager()
libraries = settings_manager.get("libraries")
if isinstance(libraries, dict) and libraries:
return list(libraries.keys())
return []
@@ -27,7 +28,8 @@ def _get_configured_libraries() -> List[str]:
def get_example_images_root() -> str:
"""Return the root directory configured for example images."""
root = settings.get("example_images_path") or ""
settings_manager = get_settings_manager()
root = settings_manager.get("example_images_path") or ""
return os.path.abspath(root) if root else ""
@@ -41,7 +43,8 @@ def uses_library_scoped_folders() -> bool:
def sanitize_library_name(library_name: Optional[str]) -> str:
"""Return a filesystem safe library name."""
name = library_name or settings.get_active_library_name() or "default"
settings_manager = get_settings_manager()
name = library_name or settings_manager.get_active_library_name() or "default"
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
return safe_name or "default"
@@ -161,11 +164,13 @@ def iter_library_roots() -> Iterable[Tuple[str, str]]:
results.append((library, get_library_root(library)))
else:
# Fall back to the active library to avoid skipping migrations/cleanup
active = settings.get_active_library_name() or "default"
settings_manager = get_settings_manager()
active = settings_manager.get_active_library_name() or "default"
results.append((active, get_library_root(active)))
return results
active = settings.get_active_library_name() or "default"
settings_manager = get_settings_manager()
active = settings_manager.get_active_library_name() or "default"
return [(active, root)]
@@ -194,10 +199,13 @@ def is_valid_example_images_root(folder_path: str) -> bool:
if item == "_deleted":
# Allow cleanup staging folders
continue
# When multi-library mode is active we expect nested hash folders
if uses_library_scoped_folders():
if _library_folder_has_only_hash_dirs(item_path):
continue
# Accept legacy library folders even when current settings do not
# explicitly enable multi-library mode. This allows users to reuse a
# previously configured example images directory after settings are
# reset, as long as the nested structure still looks like dedicated
# hash folders.
if _library_folder_has_only_hash_dirs(item_path):
continue
return False
return True

View File

@@ -6,7 +6,7 @@ import string
from aiohttp import web
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..services.service_registry import ServiceRegistry
from ..services.settings_manager import settings
from ..services.settings_manager import get_settings_manager
from ..utils.example_images_paths import get_model_folder, get_model_relative_path
from .example_images_metadata import MetadataUpdater
from ..utils.metadata_manager import MetadataManager
@@ -85,6 +85,16 @@ class ExampleImagesProcessor:
# Default fallback
return '.jpg'
@staticmethod
def _is_not_found_error(error) -> bool:
"""Return True when the downloader response represents a 404/Not Found."""
if not error:
return False
message = str(error).lower()
return '404' in message or 'file not found' in message
@staticmethod
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
"""Download images for a single model
@@ -98,7 +108,15 @@ class ExampleImagesProcessor:
image_url = image.get('url')
if not image_url:
continue
if image.get('downloadFailed'):
logger.debug(
"Skipping example image %s for %s because it previously failed to download",
image_url,
model_name,
)
continue
# Apply optimization for Civitai URLs if enabled
original_url = image_url
if optimize and 'civitai.com' in image_url:
@@ -142,7 +160,7 @@ class ExampleImagesProcessor:
with open(save_path, 'wb') as f:
f.write(content)
elif "404" in str(content):
elif ExampleImagesProcessor._is_not_found_error(content):
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
model_success = False # Mark the model as failed due to 404 error
@@ -173,7 +191,15 @@ class ExampleImagesProcessor:
image_url = image.get('url')
if not image_url:
continue
if image.get('downloadFailed'):
logger.debug(
"Skipping example image %s for %s because it previously failed to download",
image_url,
model_name,
)
continue
# Apply optimization for Civitai URLs if enabled
original_url = image_url
if optimize and 'civitai.com' in image_url:
@@ -217,7 +243,7 @@ class ExampleImagesProcessor:
with open(save_path, 'wb') as f:
f.write(content)
elif "404" in str(content):
elif ExampleImagesProcessor._is_not_found_error(content):
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
model_success = False # Mark the model as failed due to 404 error
@@ -318,7 +344,7 @@ class ExampleImagesProcessor:
try:
# Get example images path
example_images_path = settings.get('example_images_path')
example_images_path = get_settings_manager().get('example_images_path')
if not example_images_path:
raise ExampleImagesValidationError('No example images path configured')
@@ -442,7 +468,7 @@ class ExampleImagesProcessor:
}, status=400)
# Get example images path
example_images_path = settings.get('example_images_path')
example_images_path = get_settings_manager().get('example_images_path')
if not example_images_path:
return web.json_response({
'success': False,
@@ -475,15 +501,17 @@ class ExampleImagesProcessor:
'error': f"Model with hash {model_hash} not found in cache"
}, status=404)
# Check if model has custom images
if not model_data.get('civitai', {}).get('customImages'):
await MetadataManager.hydrate_model_data(model_data)
civitai_data = model_data.setdefault('civitai', {})
custom_images = civitai_data.get('customImages')
if not isinstance(custom_images, list) or not custom_images:
return web.json_response({
'success': False,
'error': f"Model has no custom images"
}, status=404)
# Find the custom image with matching short_id
custom_images = model_data['civitai']['customImages']
matching_image = None
new_custom_images = []
@@ -527,17 +555,15 @@ class ExampleImagesProcessor:
logger.warning(f"File for custom example with id {short_id} not found, but metadata will still be updated")
# Update metadata
model_data['civitai']['customImages'] = new_custom_images
civitai_data['customImages'] = new_custom_images
model_data.setdefault('civitai', {})['customImages'] = new_custom_images
# Save updated metadata to file
file_path = model_data.get('file_path')
if file_path:
try:
# Create a copy of model data without 'folder' field
model_copy = model_data.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.debug(f"Saved updated metadata for {model_data.get('model_name')}")
except Exception as e:
@@ -551,7 +577,7 @@ class ExampleImagesProcessor:
await scanner.update_single_model_cache(file_path, file_path, model_data)
# Get regular images array (might be None)
regular_images = model_data['civitai'].get('images', [])
regular_images = civitai_data.get('images', [])
return web.json_response({
'success': True,
@@ -568,4 +594,4 @@ class ExampleImagesProcessor:
}, status=500)

View File

@@ -2,7 +2,7 @@ from datetime import datetime
import os
import json
import logging
from typing import Dict, Optional, Type, Union
from typing import Any, Dict, Optional, Type, Union
from .models import BaseModelMetadata, LoraMetadata
from .file_utils import normalize_path, find_preview_file, calculate_sha256
@@ -53,6 +53,70 @@ class MetadataManager:
error_type = "Invalid JSON" if isinstance(e, json.JSONDecodeError) else "Parse error"
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
return None, True # should_skip = True
@staticmethod
async def load_metadata_payload(file_path: str) -> Dict:
"""
Load metadata and return it as a dictionary, including any unknown fields.
Falls back to reading the raw JSON file if parsing into a model class fails.
"""
payload: Dict = {}
metadata_obj, should_skip = await MetadataManager.load_metadata(file_path)
if metadata_obj:
payload = metadata_obj.to_dict()
unknown_fields = getattr(metadata_obj, "_unknown_fields", None)
if isinstance(unknown_fields, dict):
payload.update(unknown_fields)
else:
if not should_skip:
metadata_path = (
file_path
if file_path.endswith(".metadata.json")
else f"{os.path.splitext(file_path)[0]}.metadata.json"
)
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
if isinstance(raw, dict):
payload = raw
except json.JSONDecodeError:
logger.warning(
"Failed to parse metadata file %s while loading payload",
metadata_path,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning("Failed to read metadata file %s: %s", metadata_path, exc)
if not isinstance(payload, dict):
payload = {}
if file_path:
payload.setdefault("file_path", normalize_path(file_path))
return payload
@staticmethod
async def hydrate_model_data(model_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Replace the provided model data with the authoritative payload from disk.
Preserves the cached folder entry if present.
"""
file_path = model_data.get("file_path")
if not file_path:
return model_data
folder = model_data.get("folder")
payload = await MetadataManager.load_metadata_payload(file_path)
if folder is not None:
payload["folder"] = folder
model_data.clear()
model_data.update(payload)
return model_data
@staticmethod
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict]) -> bool:

View File

@@ -18,18 +18,22 @@ class BaseModelMetadata:
preview_nsfw_level: int = 0 # NSFW level of the preview image
notes: str = "" # Additional notes
from_civitai: bool = True # Whether from Civitai
civitai: Optional[Dict] = None # Civitai API data if available
civitai: Dict[str, Any] = field(default_factory=dict) # Civitai API data if available
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
db_checked: bool = False # Whether checked in archive DB
metadata_source: Optional[str] = None # Last provider that supplied metadata
last_checked_at: float = 0 # Last checked timestamp
_unknown_fields: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False) # Store unknown fields
def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue
if self.civitai is None:
self.civitai = {}
if self.tags is None:
self.tags = []

View File

@@ -0,0 +1,63 @@
"""Utilities for selecting preview media from Civitai image metadata."""
from __future__ import annotations
from typing import Mapping, Optional, Sequence, Tuple
from .constants import NSFW_LEVELS
PreviewMedia = Mapping[str, object]
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
"""Return a normalized NSFW level value for the supplied media entry."""
value = entry.get("nsfwLevel", 0)
try:
return int(value) # type: ignore[return-value]
except (TypeError, ValueError):
return 0
def select_preview_media(
images: Sequence[Mapping[str, object]] | None,
*,
blur_mature_content: bool,
) -> Tuple[Optional[PreviewMedia], int]:
"""Select the most appropriate preview media entry.
When ``blur_mature_content`` is enabled we first try to return the first media
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
available we return the media entry with the lowest NSFW level. When the
setting is disabled we simply return the first entry.
"""
if not images:
return None, 0
candidates = [item for item in images if isinstance(item, Mapping)]
if not candidates:
return None, 0
selected = candidates[0]
selected_level = _extract_nsfw_level(selected)
if not blur_mature_content:
return selected, selected_level
safe_threshold = NSFW_LEVELS.get("R", 4)
for candidate in candidates:
level = _extract_nsfw_level(candidate)
if level < safe_threshold:
return candidate, level
for candidate in candidates[1:]:
level = _extract_nsfw_level(candidate)
if level < selected_level:
selected = candidate
selected_level = level
return selected, selected_level
__all__ = ["select_preview_media"]

View File

@@ -2,10 +2,11 @@
from __future__ import annotations
import json
import logging
import os
import shutil
from typing import Optional
from typing import Any, Dict, Optional
from platformdirs import user_config_dir
@@ -36,8 +37,13 @@ def get_settings_dir(create: bool = True) -> str:
The absolute path to the user configuration directory.
"""
config_dir = user_config_dir(APP_NAME, appauthor=False)
if create:
legacy_path = get_legacy_settings_path()
if _should_use_portable_settings(legacy_path, _LOGGER):
config_dir = os.path.dirname(legacy_path)
else:
config_dir = user_config_dir(APP_NAME, appauthor=False)
if create and config_dir:
os.makedirs(config_dir, exist_ok=True)
return config_dir
@@ -64,9 +70,19 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
"""
logger = logger or _LOGGER
target_path = get_settings_file_path(create_dir=True)
legacy_path = get_legacy_settings_path()
if _should_use_portable_settings(legacy_path, logger):
return legacy_path
target_path = get_settings_file_path(create_dir=True)
preferred_dir = user_config_dir(APP_NAME, appauthor=False)
preferred_path = os.path.join(preferred_dir, "settings.json")
if os.path.abspath(target_path) != os.path.abspath(preferred_path):
os.makedirs(preferred_dir, exist_ok=True)
target_path = preferred_path
if os.path.exists(legacy_path) and not os.path.exists(target_path):
try:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
@@ -82,3 +98,63 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
return target_path
def _should_use_portable_settings(path: str, logger: logging.Logger) -> bool:
"""Return ``True`` when the repository settings file enables portable mode."""
if not os.path.exists(path):
return False
try:
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except json.JSONDecodeError as exc:
logger.warning("Failed to parse %s for portable mode flag: %s", path, exc)
return False
except OSError as exc:
logger.warning("Could not read %s to determine portable mode: %s", path, exc)
return False
if not isinstance(payload, dict):
logger.debug("Portable settings file %s does not contain a JSON object", path)
return False
flag = payload.get("use_portable_settings")
if isinstance(flag, bool):
return flag
if flag is not None:
logger.warning(
"Ignoring non-boolean use_portable_settings value in %s", path
)
return False
def load_settings_template() -> Optional[Dict[str, Any]]:
"""Return the parsed contents of ``settings.json.example`` when available."""
template_path = os.path.join(get_project_root(), "settings.json.example")
try:
with open(template_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except FileNotFoundError:
_LOGGER.debug("settings.json.example not found at %s", template_path)
return None
except json.JSONDecodeError as exc:
_LOGGER.warning("Failed to parse settings.json.example: %s", exc)
return None
except OSError as exc:
_LOGGER.warning(
"Could not read settings.json.example at %s: %s", template_path, exc
)
return None
if not isinstance(payload, dict):
_LOGGER.debug(
"settings.json.example at %s does not contain a JSON object", template_path
)
return None
return payload

104
py/utils/tag_priorities.py Normal file
View File

@@ -0,0 +1,104 @@
"""Helpers for parsing and resolving priority tag configurations."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Sequence, Set
@dataclass(frozen=True)
class PriorityTagEntry:
"""A parsed priority tag configuration entry."""
canonical: str
aliases: Set[str]
@property
def normalized_aliases(self) -> Set[str]:
return {alias.lower() for alias in self.aliases}
def _normalize_alias(alias: str) -> str:
return alias.strip()
def parse_priority_tag_string(config: str | None) -> List[PriorityTagEntry]:
"""Parse the user-facing priority tag string into structured entries."""
if not config:
return []
entries: List[PriorityTagEntry] = []
seen_canonicals: Set[str] = set()
for raw_entry in _split_priority_entries(config):
canonical, aliases = _parse_priority_entry(raw_entry)
if not canonical:
continue
normalized_canonical = canonical.lower()
if normalized_canonical in seen_canonicals:
# Skip duplicate canonicals while preserving first occurrence priority
continue
seen_canonicals.add(normalized_canonical)
alias_set = {canonical, *aliases}
cleaned_aliases = {_normalize_alias(alias) for alias in alias_set if _normalize_alias(alias)}
if not cleaned_aliases:
continue
entries.append(PriorityTagEntry(canonical=canonical, aliases=cleaned_aliases))
return entries
def _split_priority_entries(config: str) -> List[str]:
# Split on commas while respecting that users may add new lines for readability
parts = []
for chunk in config.split('\n'):
parts.extend(chunk.split(','))
return [part.strip() for part in parts if part.strip()]
def _parse_priority_entry(entry: str) -> tuple[str, Set[str]]:
if '(' in entry and entry.endswith(')'):
canonical, raw_aliases = entry.split('(', 1)
canonical = canonical.strip()
alias_section = raw_aliases[:-1] # drop trailing ')'
aliases = {alias.strip() for alias in alias_section.split('|') if alias.strip()}
return canonical, aliases
if '(' in entry and not entry.endswith(')'):
# Malformed entry; treat as literal canonical to avoid surprises
entry = entry.replace('(', '').replace(')', '')
canonical = entry.strip()
return canonical, set()
def resolve_priority_tag(
tags: Sequence[str] | Iterable[str],
entries: Sequence[PriorityTagEntry],
) -> Optional[str]:
"""Resolve the first matching canonical priority tag for the provided tags."""
tag_lookup: Dict[str, str] = {}
for tag in tags:
if not isinstance(tag, str):
continue
normalized = tag.lower()
if normalized not in tag_lookup:
tag_lookup[normalized] = tag
for entry in entries:
for alias in entry.normalized_aliases:
if alias in tag_lookup:
return entry.canonical
return None
def collect_canonical_tags(entries: Iterable[PriorityTagEntry]) -> List[str]:
"""Return the ordered list of canonical tags from the parsed entries."""
return [entry.canonical for entry in entries]

View File

@@ -1,10 +1,10 @@
from difflib import SequenceMatcher
import os
import re
from typing import Dict
from ..services.service_registry import ServiceRegistry
from ..config import config
from ..services.settings_manager import settings
from .constants import CIVITAI_MODEL_TAGS
from ..services.settings_manager import get_settings_manager
import asyncio
def get_lora_info(lora_name):
@@ -86,6 +86,41 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
# All words found either as substrings or fuzzy matches
return True
def sanitize_folder_name(name: str, replacement: str = "_") -> str:
"""Sanitize a folder name by removing or replacing invalid characters.
Args:
name: The original folder name.
replacement: The character to use when replacing invalid characters.
Returns:
A sanitized folder name safe to use across common filesystems.
"""
if not name:
return ""
# Replace invalid characters commonly restricted on Windows and POSIX
invalid_chars_pattern = r'[<>:"/\\|?*\x00-\x1f]'
sanitized = re.sub(invalid_chars_pattern, replacement, name)
# Trim whitespace introduced during sanitization
sanitized = sanitized.strip()
# Collapse repeated replacement characters to a single instance
if replacement:
sanitized = re.sub(f"{re.escape(replacement)}+", replacement, sanitized)
sanitized = sanitized.strip(replacement)
# Remove trailing spaces or periods which are invalid on Windows
sanitized = sanitized.rstrip(" .")
if not sanitized:
return "unnamed"
return sanitized
def calculate_recipe_fingerprint(loras):
"""
Calculate a unique fingerprint for a recipe based on its LoRAs.
@@ -143,7 +178,8 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
Relative path string (empty string for flat structure)
"""
# Get path template from settings for specific model type
path_template = settings.get_download_path_template(model_type)
settings_manager = get_settings_manager()
path_template = settings_manager.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure)
if not path_template:
@@ -166,28 +202,27 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
model_tags = model_data.get('tags', [])
# Apply mapping if available
base_model_mappings = settings.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)
# Find the first Civitai model tag that exists in model_tags
first_tag = ''
for civitai_tag in CIVITAI_MODEL_TAGS:
if civitai_tag in model_tags:
first_tag = civitai_tag
break
# If no Civitai model tag found, fallback to first tag
if not first_tag and model_tags:
first_tag = model_tags[0]
first_tag = settings_manager.resolve_priority_tag_for_model(model_tags, model_type)
if not first_tag:
first_tag = 'no tags' # Default if no tags available
# Format the template with available data
model_name = sanitize_folder_name(model_data.get('model_name', ''))
version_name = ''
if isinstance(civitai_data, dict):
version_name = sanitize_folder_name(civitai_data.get('name') or '')
formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
formatted_path = formatted_path.replace('{model_name}', model_name)
formatted_path = formatted_path.replace('{version_name}', version_name)
if model_type == 'embedding':
formatted_path = formatted_path.replace(' ', '_')

View File

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

View File

@@ -7,5 +7,6 @@ python_functions = test_*
# Register async marker for coroutine-style tests
markers =
asyncio: execute test within asyncio event loop
no_settings_dir_isolation: allow tests to use real settings paths
# Skip problematic directories to avoid import conflicts
norecursedirs = .git .tox dist build *.egg __pycache__ py

View File

@@ -0,0 +1,134 @@
{
"id": 1746460,
"name": "Mixplin Style [Illustrious]",
"type": "LORA",
"description": "description",
"username": "Ty_Lee",
"downloadCount": 4207,
"favoriteCount": 0,
"commentCount": 8,
"ratingCount": 0,
"rating": 0,
"is_nsfw": true,
"nsfw_level": 31,
"createdAt": "2025-07-06T01:51:42.859Z",
"updatedAt": "2025-10-10T23:15:26.714Z",
"deletedAt": null,
"tags": [
"art",
"style",
"artist style",
"styles",
"mixplin",
"artiststyle"
],
"creator_id": "Ty_Lee",
"creator_username": "Ty_Lee",
"creator_name": "Ty_Lee",
"creator_url": "/users/Ty_Lee",
"versions": [
{
"id": 2042594,
"name": "v2.0",
"href": "/models/1746460?modelVersionId=2042594"
},
{
"id": 1976567,
"name": "v1.0",
"href": "/models/1746460?modelVersionId=1976567"
}
],
"version": {
"id": 1976567,
"modelId": 1746460,
"name": "v1.0",
"baseModel": "Illustrious",
"baseModelType": "Standard",
"description": null,
"downloadCount": 437,
"ratingCount": 0,
"rating": 0,
"is_nsfw": true,
"nsfw_level": 31,
"createdAt": "2025-07-05T10:17:28.716Z",
"updatedAt": "2025-10-10T23:15:26.756Z",
"deletedAt": null,
"files": [
{
"id": 1874043,
"name": "mxpln-illustrious-ty_lee.safetensors",
"type": "Model",
"sizeKB": 223124.37109375,
"downloadUrl": "https://civitai.com/api/download/models/1976567",
"modelId": 1746460,
"modelName": "Mixplin Style [Illustrious]",
"modelVersionId": 1976567,
"is_nsfw": true,
"nsfw_level": 31,
"sha256": "e2b7a280d6539556f23f380b3f71e4e22bc4524445c4c96526e117c6005c6ad3",
"createdAt": "2025-07-05T10:17:28.716Z",
"updatedAt": "2025-10-10T23:15:26.766Z",
"is_primary": false,
"mirrors": [
{
"filename": "mxpln-illustrious-ty_lee.safetensors",
"url": "https://civitai.com/api/download/models/1976567",
"source": "civitai",
"model_id": 1746460,
"model_version_id": 1976567,
"deletedAt": null,
"is_gated": false,
"is_paid": false
}
]
}
],
"images": [
{
"id": 86403595,
"url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"nsfwLevel": 1,
"width": 1560,
"height": 2280,
"hash": "U7G8Zp0w02%IA6%N00-;D]-W~VNG0nMw-.IV",
"type": "image",
"minor": false,
"poi": false,
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null,
"image_url": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"link": "https://genur.art/posts/86403595"
}
],
"trigger": [
"mxpln"
],
"allow_download": true,
"download_url": "/api/download/models/1976567",
"platform_url": "https://civitai.com/models/1746460?modelVersionId=1976567",
"civitai_model_id": 1746460,
"civitai_model_version_id": 1976567,
"href": "/models/1746460?modelVersionId=1976567",
"mirrors": [
{
"platform": "tensorart",
"href": "/tensorart/models/904473536033245448/versions/904473536033245448",
"platform_url": "https://tensor.art/models/904473536033245448",
"name": "Mixplin Style MXP",
"version_name": "Mixplin",
"id": "904473536033245448",
"version_id": "904473536033245448"
}
]
},
"platform": "civitai",
"platform_name": "CivitAI",
"meta": {
"title": "Mixplin Style [Illustrious] - v1.0 - CivitAI Archive",
"description": "Mixplin Style [Illustrious] v1.0 is a Illustrious LORA AI model created by Ty_Lee for generating images of art, style, artist style, styles, mixplin, artiststyle",
"image": "https://img.genur.art/sig/width:450/quality:85/aHR0cHM6Ly9jLmdlbnVyLmFydC9hNmE3Njc2YS0wMWQ3LTQ1YzAtOWEzYS1mNWJiYTU4MDNiMDE=",
"canonical": "https://civarchive.com/models/1746460?modelVersionId=1976567"
}
}

38
refs/target_version.json Normal file
View File

@@ -0,0 +1,38 @@
{
"id": 2269146,
"modelId": 2004760,
"name": "v1.0 Illustrious",
"nsfwLevel": 1,
"trainedWords": ["PencilSketchDaal"],
"baseModel": "Illustrious",
"description": "<p>Illustrious. Your pencil may vary with your checkpoint. </p>",
"model": {
"name": "Pencil Sketch Anime",
"type": "LORA",
"nsfw": false,
"description": "description",
"tags": ["style"]
},
"files": [
{
"id": 2161260,
"sizeKB": 223106.37890625,
"name": "Pencil-Sketch-Illustrious.safetensors",
"type": "Model",
"hashes": {
"SHA256": "2C70479CD673B0FE056EAF4FD97C7F33A39F14853805431AC9AB84226ECE3B82"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/2269146",
"mirrors": {}
}
],
"images": [
{},
{}
],
"creator": {
"username": "Daalis",
"image": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/eb245b49-edc8-4ed6-ad7b-6d61eb8c51de/width=96/Daalis.jpeg"
}
}

View File

@@ -1,9 +1,17 @@
const settingsStore = new Map();
export const app = {
canvas: { ds: { scale: 1 } },
extensionManager: {
toast: {
add: () => {},
},
setting: {
get: (id) => (settingsStore.has(id) ? settingsStore.get(id) : undefined),
set: async (id, value) => {
settingsStore.set(id, value);
},
},
},
registerExtension: () => {},
graphToPrompt: async () => ({ workflow: { nodes: new Map() } }),

View File

@@ -1,4 +1,5 @@
{
"use_portable_settings": false,
"civitai_api_key": "your_civitai_api_key_here",
"folder_paths": {
"loras": [
@@ -14,4 +15,4 @@
"C:/path/to/another/embeddings_folder"
]
}
}
}

View File

@@ -2,7 +2,7 @@ import os
import sys
import json
from py.middleware.cache_middleware import cache_control
from py.utils.settings_paths import ensure_settings_file, get_settings_dir
from py.utils.settings_paths import ensure_settings_file
# Set environment variable to indicate standalone mode
os.environ["LORA_MANAGER_STANDALONE"] = "1"
@@ -102,8 +102,11 @@ import asyncio
import logging
from aiohttp import web
# Increase allowable header size to align with in-ComfyUI configuration.
HEADER_SIZE_LIMIT = 16384
# Setup logging
logging.basicConfig(level=logging.INFO,
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("lora-manager-standalone")
@@ -133,7 +136,14 @@ class StandaloneServer:
"""Server implementation for standalone mode"""
def __init__(self):
self.app = web.Application(logger=logger, middlewares=[cache_control])
self.app = web.Application(
logger=logger,
middlewares=[cache_control],
handler_args={
"max_field_size": HEADER_SIZE_LIMIT,
"max_line_size": HEADER_SIZE_LIMIT,
},
)
self.instance = self # Make it compatible with PromptServer.instance pattern
# Ensure the app's access logger is configured to reduce verbosity
@@ -218,54 +228,43 @@ class StandaloneServer:
from py.lora_manager import LoraManager
def validate_settings():
"""Validate that settings.json exists and has required configuration"""
settings_path = ensure_settings_file(logger)
if not os.path.exists(settings_path):
logger.error("=" * 80)
logger.error("CONFIGURATION ERROR: settings.json file not found!")
logger.error("")
logger.error("Expected location: %s", settings_path)
logger.error("")
logger.error("To run in standalone mode, you need to create a settings.json file.")
logger.error("Please follow these steps:")
logger.error("")
logger.error("1. Copy the provided settings.json.example file to create a new file")
logger.error(" named settings.json inside the LoRA Manager settings folder:")
logger.error(" %s", get_settings_dir())
logger.error("")
logger.error("2. Edit settings.json to include your correct model folder paths")
logger.error(" and CivitAI API key")
logger.error("=" * 80)
return False
# Check if settings.json has valid folder paths
"""Initialize settings and log any startup warnings."""
try:
with open(settings_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
folder_paths = settings.get('folder_paths', {})
has_valid_paths = False
for path_type in ['loras', 'checkpoints', 'embeddings']:
paths = folder_paths.get(path_type, [])
if paths and any(os.path.exists(p) for p in paths):
has_valid_paths = True
break
if not has_valid_paths:
logger.warning("=" * 80)
logger.warning("CONFIGURATION WARNING: No valid model folder paths found!")
logger.warning("")
logger.warning("Your settings.json exists but doesn't contain valid folder paths.")
logger.warning("Please check and update the folder_paths section in settings.json")
logger.warning("to include existing directories for your models.")
logger.warning("=" * 80)
return False
except Exception as e:
logger.error(f"Error reading settings.json: {e}")
from py.services.settings_manager import get_settings_manager
manager = get_settings_manager()
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to initialise settings manager: %s", exc, exc_info=True)
return False
messages = manager.get_startup_messages()
if messages:
logger.warning("=" * 80)
logger.warning("Standalone mode is using fallback configuration values.")
for message in messages:
severity = (message.get("severity") or "info").lower()
title = message.get("title")
body = message.get("message") or ""
details = message.get("details")
location = message.get("settings_file") or manager.settings_file
text = f"{title}: {body}" if title else body
log_method = logger.info
if severity == "error":
log_method = logger.error
elif severity == "warning":
log_method = logger.warning
log_method(text)
if details:
log_method("Details: %s", details)
if location:
log_method("Settings file: %s", location)
logger.warning("=" * 80)
else:
logger.info("Loaded settings from %s", manager.settings_file)
return True
class StandaloneLoraManager(LoraManager):

View File

@@ -53,6 +53,9 @@ html, body {
--lora-error: oklch(75% 0.32 29);
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
--lora-success: oklch(var(--lora-success-l) var(--lora-success-c) var(--lora-success-h));
--badge-update-bg: oklch(72% 0.2 220);
--badge-update-text: oklch(28% 0.03 220);
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
/* Spacing Scale */
--space-1: calc(8px * 1);
@@ -100,6 +103,9 @@ html[data-theme="light"] {
--lora-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(98% 0.02 256);
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
--badge-update-bg: oklch(62% 0.18 220);
--badge-update-text: oklch(98% 0.02 240);
--badge-update-glow: oklch(62% 0.18 220 / 0.4);
}
body {

View File

@@ -296,6 +296,18 @@
min-height: 20px;
}
.card-header-info {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.card-header-info .base-model-label {
flex-shrink: 1;
}
.card-actions i {
margin-left: var(--space-1);
cursor: pointer;
@@ -422,6 +434,7 @@
border-radius: var(--border-radius-xs);
backdrop-filter: blur(2px);
font-size: 0.85em;
line-height: 1.2;
}
/* Style for version name */
@@ -575,4 +588,26 @@
15% { opacity: 1; transform: translateY(0); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(0); }
}
}
.model-card.has-update {
border-color: color-mix(in oklab, var(--badge-update-bg) 60%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--badge-update-bg) 45%, transparent);
}
.model-update-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--border-radius-xs);
background: var(--badge-update-bg);
color: var(--badge-update-text);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
box-shadow: 0 4px 12px var(--badge-update-glow);
border: 1px solid color-mix(in oklab, var(--badge-update-bg) 55%, transparent);
white-space: nowrap;
}

View File

@@ -103,6 +103,23 @@
opacity: 0.7;
}
.download-transfer-stats {
margin-top: var(--space-2);
font-size: 0.85rem;
color: var(--text-color);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.download-transfer-stats .download-transfer-bytes,
.download-transfer-stats .download-transfer-speed {
display: flex;
align-items: center;
gap: var(--space-1);
white-space: nowrap;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
@@ -114,4 +131,4 @@
.current-item-bar {
transition: none;
}
}
}

View File

@@ -323,6 +323,10 @@
}
.tab-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: transparent;
border: none;
@@ -346,6 +350,51 @@
font-weight: 600;
}
.tab-btn .tab-label {
display: inline-flex;
align-items: center;
line-height: 1.2;
}
.tab-btn .tab-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--border-radius-xs);
background: var(--badge-update-bg);
color: var(--badge-update-text);
font-size: 0.68em;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
box-shadow: 0 3px 10px var(--badge-update-glow);
border: 1px solid color-mix(in oklab, var(--badge-update-bg) 55%, transparent);
line-height: 1;
}
.tab-badge--update {
animation: tab-badge-pulse 2.8s ease-in-out infinite;
}
.tab-btn--has-update:not(.active) {
color: color-mix(in oklch, var(--text-color) 70%, var(--badge-update-bg) 30%);
}
.tab-btn--has-update.active {
border-bottom-color: var(--badge-update-bg);
}
@keyframes tab-badge-pulse {
0%, 100% {
box-shadow: 0 3px 10px color-mix(in oklch, var(--badge-update-glow) 100%, transparent);
transform: translateY(0);
}
50% {
box-shadow: 0 5px 14px color-mix(in oklch, var(--badge-update-glow) 90%, transparent);
transform: translateY(-1px);
}
}
.tab-content {
position: relative;
min-height: 100px;
@@ -359,24 +408,306 @@
display: block;
}
.view-all-btn {
.recipes-header {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
background-color: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: background-color 0.2s;
font-size: 13px;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) 0 var(--space-3);
margin-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.view-all-btn:hover {
opacity: 0.9;
.recipes-header__text {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 520px;
}
.recipes-header__eyebrow {
font-size: 0.75em;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
color: var(--text-color);
opacity: 0.6;
}
.recipes-header__text h3 {
margin: 0;
font-size: 1.1em;
line-height: 1.4;
}
.recipes-header__description {
margin: 0;
font-size: 0.9em;
line-height: 1.5;
color: var(--text-color);
opacity: 0.75;
}
.recipes-header__view-all {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 8px 14px;
border: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.35);
background: transparent;
color: var(--lora-accent);
border-radius: var(--border-radius-sm);
cursor: pointer;
font-size: 0.9em;
font-weight: 600;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.recipes-header__view-all i {
font-size: 0.85em;
}
.recipes-header__view-all:hover,
.recipes-header__view-all:focus-visible {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
border-color: var(--lora-accent);
outline: none;
transform: translateY(-1px);
}
.recipes-header__view-all:active {
transform: translateY(0);
}
.recipes-card-grid {
max-width: none;
margin: var(--space-3) 0 0;
padding: 0;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-3);
row-gap: var(--space-3);
}
.recipe-card {
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-base);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 320px;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.recipe-card:hover {
transform: translateY(-4px);
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.6);
box-shadow: 0 16px 32px rgba(17, 17, 26, 0.18);
}
.recipe-card:focus-visible {
outline: 2px solid var(--lora-accent);
outline-offset: 3px;
}
.recipe-card__media {
position: relative;
overflow: hidden;
aspect-ratio: 4 / 3;
background: var(--lora-surface);
}
.recipe-card__media img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.25s ease;
}
.recipe-card:hover .recipe-card__media img {
transform: scale(1.02);
}
.recipe-card__media::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 36%;
background: linear-gradient(180deg, transparent 0%, rgba(12, 13, 24, 0.55) 100%);
pointer-events: none;
}
.recipe-card__media-top {
position: absolute;
top: var(--space-1);
right: var(--space-1);
display: flex;
gap: var(--space-1);
}
.recipe-card__copy {
background: rgba(15, 21, 40, 0.6);
border: none;
border-radius: 999px;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease, opacity 0.2s ease;
}
.recipe-card__copy i {
font-size: 0.85em;
}
.recipe-card__copy:hover,
.recipe-card__copy:focus-visible {
background: rgba(15, 21, 40, 0.8);
transform: translateY(-1px);
outline: none;
}
.recipe-card__copy:active {
transform: translateY(0);
}
[data-theme="light"] .recipe-card__copy {
background: rgba(255, 255, 255, 0.85);
color: rgba(17, 23, 41, 0.8);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
[data-theme="light"] .recipe-card__copy:hover,
[data-theme="light"] .recipe-card__copy:focus-visible {
background: rgba(255, 255, 255, 0.95);
}
.recipe-card__body {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2);
flex: 1;
}
.recipe-card__title {
margin: 0;
font-size: 1.05em;
line-height: 1.4;
font-weight: 600;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.recipe-card__meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.recipe-card__badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.78em;
font-weight: 600;
line-height: 1;
background: rgba(255, 255, 255, 0.08);
color: var(--text-color);
}
.recipe-card__badge i {
font-size: 0.85em;
}
.recipe-card__badge--base {
background: rgba(255, 255, 255, 0.1);
color: var(--text-color);
}
[data-theme="light"] .recipe-card__badge {
background: rgba(15, 23, 42, 0.08);
}
[data-theme="light"] .recipe-card__badge--base {
background: rgba(15, 23, 42, 0.12);
}
.recipe-card__badge--ready {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
}
.recipe-card__badge--missing {
background: rgba(234, 179, 8, 0.2);
color: #facc15;
}
.recipe-card__badge--empty {
background: rgba(148, 163, 184, 0.18);
color: #e2e8f0;
}
[data-theme="light"] .recipe-card__badge--ready {
color: #157347;
background: rgba(76, 167, 120, 0.16);
}
[data-theme="light"] .recipe-card__badge--missing {
color: #9f580a;
background: rgba(245, 199, 43, 0.22);
}
[data-theme="light"] .recipe-card__badge--empty {
color: rgba(71, 85, 105, 0.9);
background: rgba(148, 163, 184, 0.2);
}
.recipe-card__cta {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--lora-accent);
font-size: 0.9em;
font-weight: 600;
pointer-events: none;
}
.recipe-card__cta i {
font-size: 0.85em;
transition: transform 0.2s ease;
}
.recipe-card:hover .recipe-card__cta i {
transform: translateX(4px);
}
@media (max-width: 900px) {
.recipes-header {
flex-direction: column;
align-items: flex-start;
}
.recipes-header__view-all {
align-self: flex-start;
}
}
@media (max-width: 640px) {
.recipes-card-grid {
grid-template-columns: 1fr;
}
}
/* Loading, error and empty states */
.recipes-loading,
.recipes-error,
@@ -491,4 +822,4 @@
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -0,0 +1,337 @@
.model-versions-tab {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-2) 0;
}
.versions-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2);
background: color-mix(in oklch, var(--lora-surface) 70%, transparent);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.versions-toolbar-info h3 {
margin: 0 0 4px;
font-size: 1.05rem;
font-weight: 600;
color: var(--text-color);
}
.versions-toolbar-info p {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.versions-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.versions-toolbar-btn {
appearance: none;
border-radius: var(--border-radius-xs);
padding: 8px 14px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.versions-toolbar-btn-primary {
background: var(--lora-accent);
color: #fff;
border-color: color-mix(in oklch, var(--lora-accent) 70%, transparent);
}
.versions-toolbar-btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
background: color-mix(in oklch, var(--lora-accent) 85%, transparent);
}
.versions-toolbar-btn-secondary {
background: transparent;
color: var(--text-muted);
border-color: var(--border-color);
}
.versions-toolbar-btn-secondary:hover:not(:disabled) {
color: var(--text-color);
}
.versions-toolbar-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.versions-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.version-divider {
height: 1px;
background: var(--border-color);
margin: var(--space-1) 0;
}
.model-version-row {
display: grid;
grid-template-columns: 124px 1fr auto;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
[data-theme="dark"] .model-version-row {
background: color-mix(in oklch, var(--card-bg) 88%, black 12%);
}
.model-version-row:hover {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.model-version-row.is-clickable {
cursor: pointer;
}
.model-version-row.is-current {
border-color: var(--lora-accent);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent),
0 10px 22px rgba(0, 0, 0, 0.12);
}
.version-media {
width: 124px;
height: 88px;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: rgba(0, 0, 0, 0.03);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in oklch, var(--border-color) 70%, transparent);
}
.version-media img,
.version-media video {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-media img {
/* Bias cropping toward the upper region to keep faces visible */
object-position: center 20%;
}
.version-media video {
background: #000;
}
.version-media-placeholder {
font-size: 0.85rem;
color: var(--text-muted);
border-style: dashed;
border-width: 1px;
}
.version-details {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.version-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 0.95rem;
}
.versions-tab-version-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.version-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.version-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.version-badge-info {
background: color-mix(in oklch, var(--badge-update-bg) 25%, transparent);
color: var(--badge-update-bg);
border-color: color-mix(in oklch, var(--badge-update-bg) 55%, transparent);
}
.version-badge-success {
background: color-mix(in oklch, var(--lora-success) 25%, transparent);
color: var(--lora-success);
border-color: color-mix(in oklch, var(--lora-success) 50%, transparent);
}
.version-badge-muted {
background: color-mix(in oklch, var(--text-muted) 18%, transparent);
color: var(--text-muted);
border-color: color-mix(in oklch, var(--text-muted) 40%, transparent);
}
.version-badge-current {
background: color-mix(in oklch, var(--lora-accent) 22%, transparent);
color: var(--lora-accent);
border-color: color-mix(in oklch, var(--lora-accent) 55%, transparent);
}
.version-meta {
font-size: 0.8rem;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.version-meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.version-meta-primary {
font-weight: 600;
color: color-mix(in oklch, var(--text-color) 88%, var(--lora-accent) 12%);
}
.version-meta-separator {
color: color-mix(in oklch, var(--text-muted) 90%, var(--text-color) 10%);
}
.version-actions {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
.version-action {
min-width: 128px;
padding: 7px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.version-action-primary {
background: var(--lora-accent);
color: #fff;
border-color: color-mix(in oklch, var(--lora-accent) 65%, transparent);
}
.version-action-primary:hover {
transform: translateY(-1px);
background: color-mix(in oklch, var(--lora-accent) 85%, transparent);
}
.version-action-danger {
background: transparent;
border-color: color-mix(in oklch, var(--lora-error) 60%, transparent);
color: var(--lora-error);
}
.version-action-danger:hover {
background: color-mix(in oklch, var(--lora-error) 12%, transparent);
}
.version-action-ghost {
background: transparent;
border-color: var(--border-color);
color: var(--text-color);
}
.version-action-ghost:hover {
background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
}
.version-action:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.versions-loading-state,
.versions-empty,
.versions-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: var(--space-3);
border: 1px dashed var(--lora-border);
border-radius: var(--border-radius-sm);
color: var(--text-muted);
text-align: center;
}
.versions-error {
border-style: solid;
border-color: color-mix(in oklch, var(--lora-error) 45%, transparent);
color: var(--lora-error);
}
.versions-empty i,
.versions-error i {
font-size: 1.25rem;
}
@media (max-width: 900px) {
.model-version-row {
grid-template-columns: 1fr;
align-items: stretch;
}
.version-actions {
flex-direction: row;
justify-content: flex-end;
flex-wrap: wrap;
}
.version-action {
min-width: 0;
}
}

View File

@@ -35,34 +35,38 @@
margin: 0;
}
.settings-open-location-button {
.settings-action-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
background: none;
color: var(--text-color);
opacity: 0.6;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: opacity 0.2s ease, background-color 0.2s ease;
text-decoration: none;
line-height: 1;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.settings-open-location-button:hover,
.settings-open-location-button:focus-visible {
.settings-action-link:hover,
.settings-action-link:focus-visible {
opacity: 1;
color: var(--lora-accent);
background-color: rgba(var(--border-color-rgb, 148, 163, 184), 0.2);
border-color: rgba(var(--border-color-rgb, 148, 163, 184), 0.4);
outline: none;
}
.settings-open-location-button i {
.settings-action-link i {
font-size: 1em;
}
.settings-open-location-button:focus-visible {
box-shadow: 0 0 0 2px rgba(var(--border-color-rgb, 148, 163, 184), 0.6);
.settings-action-link:focus-visible {
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.2);
}
/* Settings Links */
@@ -204,6 +208,149 @@
width: 100%; /* Full width */
}
.settings-help-text {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
margin-bottom: var(--space-2);
line-height: 1.4;
}
.settings-help-text.subtle {
font-size: 0.85em;
opacity: 0.7;
margin-top: var(--space-1);
}
.priority-tags-input {
width: 97%;
min-height: 72px;
padding: 8px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
resize: vertical;
}
.priority-tags-input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.priority-tags-item {
gap: var(--space-2);
}
.priority-tags-header {
align-items: center;
justify-content: flex-start;
}
.priority-tags-info {
display: flex;
align-items: center;
gap: var(--space-1);
width: auto;
}
.priority-tags-info label {
display: inline-flex;
align-items: center;
margin-bottom: 0;
}
.priority-tags-example {
font-size: 0.85em;
opacity: 0.8;
margin-top: var(--space-1);
}
.priority-tags-example code {
font-family: var(--code-font, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
background-color: rgba(var(--lora-accent-rgb, 79, 70, 229), 0.12);
padding: 2px 6px;
border-radius: var(--border-radius-xs);
display: inline-block;
}
.priority-tags-tabs {
position: relative;
}
.priority-tags-tab-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.priority-tags-tablist {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-1);
}
.priority-tags-tab-label {
flex: 1;
text-align: center;
padding: var(--space-1) var(--space-2);
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
opacity: 0.7;
}
.priority-tags-tab-label:hover,
.priority-tags-tab-label:focus {
opacity: 1;
color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
}
.priority-tags-panels {
margin-top: var(--space-2);
}
.priority-tags-panel {
display: none;
}
#priority-tags-tab-lora:checked ~ .priority-tags-tablist label[for="priority-tags-tab-lora"],
#priority-tags-tab-checkpoint:checked ~ .priority-tags-tablist label[for="priority-tags-tab-checkpoint"],
#priority-tags-tab-embedding:checked ~ .priority-tags-tablist label[for="priority-tags-tab-embedding"] {
border-bottom-color: var(--lora-accent);
color: var(--lora-accent);
opacity: 1;
font-weight: 600;
}
#priority-tags-tab-lora:checked ~ .priority-tags-panels #priority-tags-panel-lora,
#priority-tags-tab-checkpoint:checked ~ .priority-tags-panels #priority-tags-panel-checkpoint,
#priority-tags-tab-embedding:checked ~ .priority-tags-panels #priority-tags-panel-embedding {
display: block;
}
.priority-tags-input.settings-input-error {
border-color: var(--danger-color, #dc2626);
box-shadow: 0 0 0 2px rgba(220, 38, 38, 0.12);
}
.settings-input-error-message {
font-size: 0.8em;
color: var(--danger-color, #dc2626);
display: none;
}
.metadata-suggestions-loading {
font-size: 0.85em;
opacity: 0.7;
padding: 6px 0;
}
/* Settings Styles */
.settings-section {
margin-top: var(--space-3);
@@ -670,4 +817,4 @@ input:checked + .toggle-slider:before {
padding-top: var(--space-2);
margin-top: var(--space-2);
}
}
}

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