feat(settings): add auto-organize exclusions

This commit is contained in:
pixelpaws
2025-11-20 16:08:32 +08:00
parent 5093c30c06
commit 07721af87c
21 changed files with 339 additions and 18 deletions

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "Download-Pfad-Vorlagen",
"exampleImages": "Beispielbilder",
"updateFlags": "Update-Markierungen",
"autoOrganize": "Auto-organize",
"misc": "Verschiedenes",
"metadataArchive": "Metadaten-Archiv-Datenbank",
"storageLocation": "Einstellungsort",
@@ -251,6 +252,15 @@
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
"autoplayOnHoverHelp": "Video-Vorschauen nur beim Darüberfahren mit der Maus abspielen"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "Anzeige-Dichte",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "Download Path Templates",
"exampleImages": "Example Images",
"updateFlags": "Update Flags",
"autoOrganize": "Auto-organize",
"misc": "Misc.",
"metadataArchive": "Metadata Archive Database",
"storageLocation": "Settings Location",
@@ -251,6 +252,15 @@
"autoplayOnHover": "Autoplay Videos on Hover",
"autoplayOnHoverHelp": "Only play video previews when hovering over them"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "Display Density",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "Plantillas de rutas de descarga",
"exampleImages": "Imágenes de ejemplo",
"updateFlags": "Indicadores de actualización",
"autoOrganize": "Auto-organize",
"misc": "Varios",
"metadataArchive": "Base de datos de archivo de metadatos",
"storageLocation": "Ubicación de ajustes",
@@ -251,6 +252,15 @@
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
"autoplayOnHoverHelp": "Solo reproducir vistas previas de video al pasar el ratón sobre ellas"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "Densidad de visualización",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "Modèles de chemin de téléchargement",
"exampleImages": "Images d'exemple",
"updateFlags": "Indicateurs de mise à jour",
"autoOrganize": "Auto-organize",
"misc": "Divers",
"metadataArchive": "Base de données d'archive des métadonnées",
"storageLocation": "Emplacement des paramètres",
@@ -251,6 +252,15 @@
"autoplayOnHover": "Lecture automatique vidéo au survol",
"autoplayOnHoverHelp": "Lire les aperçus vidéo uniquement lors du survol"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "Densité d'affichage",
"displayDensityOptions": {

View File

@@ -231,6 +231,7 @@
"downloadPathTemplates": "תבניות נתיב הורדה",
"exampleImages": "תמונות דוגמה",
"updateFlags": "תגי עדכון",
"autoOrganize": "Auto-organize",
"misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"storageLocation": "מיקום ההגדרות",
@@ -251,6 +252,15 @@
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
"autoplayOnHoverHelp": "נגן תצוגות מקדימות של וידאו רק בעת ריחוף מעליהן"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "צפיפות תצוגה",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "ダウンロードパステンプレート",
"exampleImages": "例画像",
"updateFlags": "アップデートフラグ",
"autoOrganize": "Auto-organize",
"misc": "その他",
"metadataArchive": "メタデータアーカイブデータベース",
"storageLocation": "設定の場所",
@@ -251,6 +252,15 @@
"autoplayOnHover": "ホバー時に動画を自動再生",
"autoplayOnHoverHelp": "動画プレビューはホバー時にのみ再生されます"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "表示密度",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "다운로드 경로 템플릿",
"exampleImages": "예시 이미지",
"updateFlags": "업데이트 표시",
"autoOrganize": "Auto-organize",
"misc": "기타",
"metadataArchive": "메타데이터 아카이브 데이터베이스",
"storageLocation": "설정 위치",
@@ -251,6 +252,15 @@
"autoplayOnHover": "호버 시 비디오 자동 재생",
"autoplayOnHoverHelp": "마우스를 올렸을 때만 비디오 미리보기를 재생합니다"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "표시 밀도",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "Шаблоны путей загрузки",
"exampleImages": "Примеры изображений",
"updateFlags": "Метки обновлений",
"autoOrganize": "Auto-organize",
"misc": "Разное",
"metadataArchive": "Архив метаданных",
"storageLocation": "Расположение настроек",
@@ -251,6 +252,15 @@
"autoplayOnHover": "Автовоспроизведение видео при наведении",
"autoplayOnHoverHelp": "Воспроизводить превью видео только при наведении курсора"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "Плотность отображения",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "下载路径模板",
"exampleImages": "示例图片",
"updateFlags": "更新标记",
"autoOrganize": "Auto-organize",
"misc": "其他",
"metadataArchive": "元数据归档数据库",
"storageLocation": "设置位置",
@@ -251,6 +252,15 @@
"autoplayOnHover": "悬停时自动播放视频",
"autoplayOnHoverHelp": "仅在悬停时播放视频预览"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "显示密度",
"displayDensityOptions": {

View File

@@ -232,6 +232,7 @@
"downloadPathTemplates": "下載路徑範本",
"exampleImages": "範例圖片",
"updateFlags": "更新標記",
"autoOrganize": "Auto-organize",
"misc": "其他",
"metadataArchive": "中繼資料封存資料庫",
"storageLocation": "設定位置",
@@ -251,6 +252,15 @@
"autoplayOnHover": "滑鼠懸停自動播放影片",
"autoplayOnHoverHelp": "僅在滑鼠懸停時播放影片預覽"
},
"autoOrganizeExclusions": {
"label": "Auto-organize exclusions",
"placeholder": "Example: extras/*, */backups/*; *_temp.safetensors",
"help": "Skip moving files that match these wildcard patterns. Separate multiple patterns with commas or semicolons.",
"validation": {
"noPatterns": "Enter at least one pattern separated by commas or semicolons.",
"saveFailed": "Unable to save exclusions: {message}"
}
},
"layoutSettings": {
"displayDensity": "顯示密度",
"displayDensityOptions": {

View File

@@ -16,7 +16,7 @@ from ...services.download_coordinator import DownloadCoordinator
from ...services.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService
from ...services.preview_asset_service import PreviewAssetService
from ...services.settings_manager import SettingsManager
from ...services.settings_manager import SettingsManager, get_settings_manager
from ...services.tag_update_service import TagUpdateService
from ...services.use_cases import (
AutoOrganizeInProgressError,
@@ -1051,16 +1051,23 @@ class ModelAutoOrganizeHandler:
async def auto_organize_models(self, request: web.Request) -> web.Response:
try:
file_paths = None
exclusion_patterns = None
settings_manager = get_settings_manager()
if request.method == "POST":
try:
data = await request.json()
file_paths = data.get("file_paths")
if "exclusion_patterns" in data:
exclusion_patterns = settings_manager.normalize_auto_organize_exclusions(
data.get("exclusion_patterns")
)
except Exception: # pragma: no cover - permissive path
pass
result = await self._use_case.execute(
file_paths=file_paths,
progress_callback=self._progress_callback,
exclusion_patterns=exclusion_patterns,
)
return web.json_response(result.to_dict())
except AutoOrganizeInProgressError:

View File

@@ -1,7 +1,8 @@
import asyncio
import fnmatch
import os
import logging
from typing import List, Dict, Optional, Any, Set
from typing import Any, Dict, List, Optional, Sequence, Set
from abc import ABC, abstractmethod
from ..utils.utils import calculate_relative_path_for_model, remove_empty_dirs
@@ -79,9 +80,10 @@ class ModelFileService:
return self.scanner.get_model_roots()
async def auto_organize_models(
self,
self,
file_paths: Optional[List[str]] = None,
progress_callback: Optional[ProgressCallback] = None
progress_callback: Optional[ProgressCallback] = None,
exclusion_patterns: Optional[Sequence[str]] = None,
) -> AutoOrganizeResult:
"""Auto-organize models based on current settings
@@ -100,6 +102,13 @@ class ModelFileService:
# Get all models from cache
cache = await self.scanner.get_cached_data()
all_models = cache.raw_data
settings_manager = get_settings_manager()
normalized_exclusions = settings_manager.normalize_auto_organize_exclusions(
exclusion_patterns
if exclusion_patterns is not None
else settings_manager.get_auto_organize_exclusions()
)
# Filter models if specific file paths are provided
if file_paths:
@@ -107,7 +116,16 @@ class ModelFileService:
result.operation_type = 'bulk'
else:
result.operation_type = 'all'
if normalized_exclusions:
all_models = [
model
for model in all_models
if not self._should_exclude_model(
model.get('file_path'), normalized_exclusions
)
]
# Get model roots for this scanner
model_roots = self.get_model_roots()
if not model_roots:
@@ -301,10 +319,24 @@ class ModelFileService:
# Normalize paths for comparison
normalized_root = os.path.normpath(root).replace(os.sep, '/')
normalized_file = os.path.normpath(file_path).replace(os.sep, '/')
if normalized_file.startswith(normalized_root):
return root
return None
def _should_exclude_model(
self, file_path: Optional[str], patterns: Sequence[str]
) -> bool:
if not file_path or not patterns:
return False
normalized_path = os.path.normpath(file_path).replace(os.sep, '/')
filename = os.path.basename(normalized_path)
for pattern in patterns:
if fnmatch.fnmatch(filename, pattern) or fnmatch.fnmatch(normalized_path, pattern):
return True
return False
async def _calculate_target_directory(
self,

View File

@@ -62,6 +62,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"model_name_display": "model_name",
"model_card_footer_action": "example_images",
"update_flag_strategy": "same_base",
"auto_organize_exclusions": [],
}
@@ -239,6 +240,17 @@ class SettingsManager:
)
inserted_defaults = True
if "auto_organize_exclusions" in self.settings:
normalized_exclusions = self.normalize_auto_organize_exclusions(
self.settings.get("auto_organize_exclusions")
)
if normalized_exclusions != self.settings.get("auto_organize_exclusions"):
self.settings["auto_organize_exclusions"] = normalized_exclusions
updated_existing = True
else:
self.settings["auto_organize_exclusions"] = []
inserted_defaults = True
for key, value in defaults.items():
if key == "priority_tags":
continue
@@ -719,6 +731,7 @@ class SettingsManager:
defaults['download_path_templates'] = {}
defaults['priority_tags'] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault('folder_paths', {})
defaults['auto_organize_exclusions'] = []
library_name = defaults.get("active_library") or "default"
default_library = self._build_library_payload(
@@ -744,6 +757,35 @@ class SettingsManager:
return normalized
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
candidates: Iterable[str] = (
value.replace("\n", ",").replace(";", ",").split(",")
)
elif isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray, str)):
candidates = value
else:
return []
patterns: List[str] = []
for raw in candidates:
if isinstance(raw, str):
token = raw.strip()
if token:
patterns.append(token)
unique_patterns: List[str] = []
seen = set()
for pattern in patterns:
if pattern not in seen:
seen.add(pattern)
unique_patterns.append(pattern)
return unique_patterns
def get_priority_tag_config(self) -> Dict[str, str]:
stored_value = self.settings.get("priority_tags")
normalized = self._normalize_priority_tag_config(stored_value)
@@ -752,6 +794,15 @@ class SettingsManager:
self._save_settings()
return normalized.copy()
def get_auto_organize_exclusions(self) -> List[str]:
exclusions = self.normalize_auto_organize_exclusions(
self.settings.get("auto_organize_exclusions")
)
if exclusions != self.settings.get("auto_organize_exclusions"):
self.settings["auto_organize_exclusions"] = exclusions
self._save_settings()
return exclusions
def get_startup_messages(self) -> List[Dict[str, Any]]:
return [message.copy() for message in self._startup_messages]
@@ -787,6 +838,8 @@ class SettingsManager:
def set(self, key: str, value: Any) -> None:
"""Set setting value and save"""
if key == "auto_organize_exclusions":
value = self.normalize_auto_organize_exclusions(value)
self.settings[key] = value
portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool):

View File

@@ -39,6 +39,7 @@ class AutoOrganizeUseCase:
*,
file_paths: Optional[Sequence[str]] = None,
progress_callback: Optional[ProgressCallback] = None,
exclusion_patterns: Optional[Sequence[str]] = None,
) -> AutoOrganizeResult:
"""Run the auto-organize routine guarded by a shared lock."""
@@ -53,4 +54,5 @@ class AutoOrganizeUseCase:
return await self._file_service.auto_organize_models(
file_paths=list(file_paths) if file_paths is not None else None,
progress_callback=progress_callback,
exclusion_patterns=exclusion_patterns,
)

View File

@@ -14,5 +14,6 @@
"C:/path/to/your/embeddings_folder",
"C:/path/to/another/embeddings_folder"
]
}
},
"auto_organize_exclusions": []
}

View File

@@ -1252,15 +1252,24 @@ export class BaseModelApiClient {
// Start the auto-organize operation
const endpoint = this.apiConfig.endpoints.autoOrganize;
const requestOptions = {
method: filePaths ? 'POST' : 'GET',
headers: filePaths ? { 'Content-Type': 'application/json' } : {}
};
const exclusionPatterns = (state.global.settings.auto_organize_exclusions || [])
.filter(pattern => typeof pattern === 'string' && pattern.trim())
.map(pattern => pattern.trim());
const requestBody = {};
if (filePaths) {
requestOptions.body = JSON.stringify({ file_paths: filePaths });
requestBody.file_paths = filePaths;
}
if (exclusionPatterns.length > 0) {
requestBody.exclusion_patterns = exclusionPatterns;
}
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
};
const response = await fetch(endpoint, requestOptions);
if (!response.ok) {

View File

@@ -131,11 +131,36 @@ export class SettingsManager {
}
merged.priority_tags = normalizedPriority;
merged.auto_organize_exclusions = this.normalizePatternList(
backendSettings?.auto_organize_exclusions ?? defaults.auto_organize_exclusions
);
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
return merged;
}
normalizePatternList(value) {
if (Array.isArray(value)) {
const sanitized = value
.map(item => typeof item === 'string' ? item.trim() : '')
.filter(Boolean);
return [...new Set(sanitized)];
}
if (typeof value === 'string') {
const sanitized = value
.replace(/\n/g, ',')
.replace(/;/g, ',')
.split(',')
.map(part => part.trim())
.filter(Boolean);
return [...new Set(sanitized)];
}
return [];
}
registerStartupMessages(messages = []) {
if (!Array.isArray(messages) || messages.length === 0) {
return;
@@ -376,6 +401,16 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
}
const autoOrganizeExclusionsInput = document.getElementById('autoOrganizeExclusions');
if (autoOrganizeExclusionsInput) {
const patterns = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
autoOrganizeExclusionsInput.value = patterns.join(', ');
}
const autoOrganizeExclusionsError = document.getElementById('autoOrganizeExclusionsError');
if (autoOrganizeExclusionsError) {
autoOrganizeExclusionsError.textContent = '';
}
// Set video autoplay on hover setting
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
if (autoplayOnHoverCheckbox) {
@@ -1592,11 +1627,63 @@ export class SettingsManager {
}
}
}
async saveAutoOrganizeExclusions() {
const input = document.getElementById('autoOrganizeExclusions');
const errorElement = document.getElementById('autoOrganizeExclusionsError');
if (!input) return;
const normalized = this.normalizePatternList(input.value);
if (input.value.trim() && normalized.length === 0) {
if (errorElement) {
errorElement.textContent = translate(
'settings.autoOrganizeExclusions.validation.noPatterns',
{},
'Enter at least one pattern separated by commas or semicolons.'
);
}
return;
}
const current = this.normalizePatternList(state.global.settings.auto_organize_exclusions);
if (normalized.join('|') === current.join('|')) {
if (errorElement) {
errorElement.textContent = '';
}
return;
}
try {
if (errorElement) {
errorElement.textContent = '';
}
await this.saveSetting('auto_organize_exclusions', normalized);
input.value = normalized.join(', ');
showToast(
'toast.settings.settingsUpdated',
{ setting: translate('settings.autoOrganizeExclusions.label') },
'success'
);
} catch (error) {
console.error('Failed to save auto-organize exclusions:', error);
if (errorElement) {
errorElement.textContent = translate(
'settings.autoOrganizeExclusions.validation.saveFailed',
{ message: error.message },
`Unable to save exclusions: ${error.message}`
);
}
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
async saveInputSetting(elementId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
const value = element.value.trim(); // Trim whitespace
try {

View File

@@ -34,6 +34,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
update_flag_strategy: 'same_base',
auto_organize_exclusions: [],
});
export function createDefaultSettings() {

View File

@@ -341,6 +341,29 @@
</div>
</div>
<div class="settings-section">
<h3>{{ t('settings.sections.autoOrganize') }}</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="autoOrganizeExclusions">{{ t('settings.autoOrganizeExclusions.label') }}</label>
</div>
<div class="setting-control">
<textarea
id="autoOrganizeExclusions"
rows="3"
placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"
onblur="settingsManager.saveAutoOrganizeExclusions()"
></textarea>
</div>
</div>
<div class="input-help">
{{ t('settings.autoOrganizeExclusions.help') }}
</div>
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
</div>
</div>
<!-- Default Path Customization Section -->
<div class="settings-section">
<h3>{{ t('settings.downloadPathTemplates.title') }}</h3>

View File

@@ -540,7 +540,7 @@ def test_auto_organize_progress_returns_latest_snapshot(mock_service):
def test_auto_organize_route_emits_progress(mock_service, monkeypatch: pytest.MonkeyPatch):
async def fake_auto_organize(self, file_paths=None, progress_callback=None):
async def fake_auto_organize(self, file_paths=None, progress_callback=None, exclusion_patterns=None):
result = AutoOrganizeResult()
result.total = 1
result.processed = 1

View File

@@ -53,10 +53,15 @@ class StubFileService:
*,
file_paths: Optional[List[str]] = None,
progress_callback=None,
exclusion_patterns=None,
) -> AutoOrganizeResult:
result = AutoOrganizeResult()
result.total = len(file_paths or [])
self.calls.append({"file_paths": file_paths, "progress_callback": progress_callback})
self.calls.append({
"file_paths": file_paths,
"progress_callback": progress_callback,
"exclusion_patterns": exclusion_patterns,
})
return result
@@ -144,6 +149,7 @@ async def test_auto_organize_use_case_executes_with_lock() -> None:
assert isinstance(result, AutoOrganizeResult)
assert file_service.calls[0]["file_paths"] == ["model1"]
assert file_service.calls[0]["exclusion_patterns"] is None
async def test_auto_organize_use_case_rejects_when_running() -> None: