mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: Enhance model moving functionality with improved error handling and unique filename generation
This commit is contained in:
@@ -684,19 +684,27 @@ class BaseModelRoutes(ABC):
|
|||||||
source_dir = os.path.dirname(file_path)
|
source_dir = os.path.dirname(file_path)
|
||||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
|
||||||
if os.path.exists(target_file_path):
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': True,
|
||||||
'error': f"Target file already exists: {target_file_path}"
|
'message': 'Source and target directories are the same',
|
||||||
}, status=409)
|
'original_file_path': file_path,
|
||||||
success = await self.service.scanner.move_model(file_path, target_path)
|
'new_file_path': file_path
|
||||||
if success:
|
})
|
||||||
return web.json_response({'success': True, 'new_file_path': target_file_path})
|
|
||||||
|
new_file_path = await self.service.scanner.move_model(file_path, target_path)
|
||||||
|
if new_file_path:
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': new_file_path
|
||||||
|
})
|
||||||
else:
|
else:
|
||||||
return web.Response(text='Failed to move model', status=500)
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Failed to move model',
|
||||||
|
'original_file_path': file_path,
|
||||||
|
'new_file_path': None
|
||||||
|
}, status=500)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
return web.Response(text=str(e), status=500)
|
return web.Response(text=str(e), status=500)
|
||||||
@@ -715,26 +723,28 @@ class BaseModelRoutes(ABC):
|
|||||||
source_dir = os.path.dirname(file_path)
|
source_dir = os.path.dirname(file_path)
|
||||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
results.append({
|
results.append({
|
||||||
"path": file_path,
|
"original_file_path": file_path,
|
||||||
|
"new_file_path": file_path,
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Source and target directories are the same"
|
"message": "Source and target directories are the same"
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
file_name = os.path.basename(file_path)
|
|
||||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
new_file_path = await self.service.scanner.move_model(file_path, target_path)
|
||||||
if os.path.exists(target_file_path):
|
if new_file_path:
|
||||||
results.append({
|
results.append({
|
||||||
"path": file_path,
|
"original_file_path": file_path,
|
||||||
"success": False,
|
"new_file_path": new_file_path,
|
||||||
"message": f"Target file already exists: {target_file_path}"
|
"success": True,
|
||||||
|
"message": "Success"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
"original_file_path": file_path,
|
||||||
|
"new_file_path": None,
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to move model"
|
||||||
})
|
})
|
||||||
continue
|
|
||||||
success = await self.service.scanner.move_model(file_path, target_path)
|
|
||||||
results.append({
|
|
||||||
"path": file_path,
|
|
||||||
"success": success,
|
|
||||||
"message": "Success" if success else "Failed to move model"
|
|
||||||
})
|
|
||||||
success_count = sum(1 for r in results if r["success"])
|
success_count = sum(1 for r in results if r["success"])
|
||||||
failure_count = len(results) - success_count
|
failure_count = len(results) - success_count
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
|
|||||||
@@ -792,8 +792,16 @@ class ModelScanner:
|
|||||||
logger.error(f"Error adding model to cache: {e}")
|
logger.error(f"Error adding model to cache: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def move_model(self, source_path: str, target_path: str) -> bool:
|
async def move_model(self, source_path: str, target_path: str) -> Optional[str]:
|
||||||
"""Move a model and its associated files to a new location"""
|
"""Move a model and its associated files to a new location
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_path: Original file path
|
||||||
|
target_path: Target directory path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[str]: New file path if successful, None if failed
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
source_path = source_path.replace(os.sep, '/')
|
source_path = source_path.replace(os.sep, '/')
|
||||||
target_path = target_path.replace(os.sep, '/')
|
target_path = target_path.replace(os.sep, '/')
|
||||||
@@ -802,14 +810,37 @@ class ModelScanner:
|
|||||||
|
|
||||||
if not file_ext or file_ext.lower() not in self.file_extensions:
|
if not file_ext or file_ext.lower() not in self.file_extensions:
|
||||||
logger.error(f"Invalid file extension for model: {file_ext}")
|
logger.error(f"Invalid file extension for model: {file_ext}")
|
||||||
return False
|
return None
|
||||||
|
|
||||||
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
base_name = os.path.splitext(os.path.basename(source_path))[0]
|
||||||
source_dir = os.path.dirname(source_path)
|
source_dir = os.path.dirname(source_path)
|
||||||
|
|
||||||
os.makedirs(target_path, exist_ok=True)
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
|
||||||
target_file = os.path.join(target_path, f"{base_name}{file_ext}").replace(os.sep, '/')
|
# Get SHA256 hash of the source file for conflict resolution
|
||||||
|
source_hash = self.get_hash_by_path(source_path)
|
||||||
|
if not source_hash:
|
||||||
|
# Calculate hash if not in cache
|
||||||
|
try:
|
||||||
|
import hashlib
|
||||||
|
with open(source_path, 'rb') as f:
|
||||||
|
source_hash = hashlib.sha256(f.read()).hexdigest().lower()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to calculate hash for {source_path}: {e}")
|
||||||
|
source_hash = "unknown"
|
||||||
|
|
||||||
|
# Check for filename conflicts and auto-rename if necessary
|
||||||
|
from ..utils.models import BaseModelMetadata
|
||||||
|
final_filename = BaseModelMetadata.generate_unique_filename(
|
||||||
|
target_path, base_name, file_ext, source_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
target_file = os.path.join(target_path, final_filename).replace(os.sep, '/')
|
||||||
|
final_base_name = os.path.splitext(final_filename)[0]
|
||||||
|
|
||||||
|
# Log if filename was changed due to conflict
|
||||||
|
if final_filename != f"{base_name}{file_ext}":
|
||||||
|
logger.info(f"Renamed {base_name}{file_ext} to {final_filename} to avoid filename conflict")
|
||||||
|
|
||||||
real_source = os.path.realpath(source_path)
|
real_source = os.path.realpath(source_path)
|
||||||
real_target = os.path.realpath(target_file)
|
real_target = os.path.realpath(target_file)
|
||||||
@@ -826,12 +857,17 @@ class ModelScanner:
|
|||||||
for file in os.listdir(source_dir):
|
for file in os.listdir(source_dir):
|
||||||
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
|
||||||
source_file_path = os.path.join(source_dir, file)
|
source_file_path = os.path.join(source_dir, file)
|
||||||
|
# Generate new filename with the same base name as the model file
|
||||||
|
file_suffix = file[len(base_name):] # Get the part after base_name (e.g., ".metadata.json", ".preview.png")
|
||||||
|
new_associated_filename = f"{final_base_name}{file_suffix}"
|
||||||
|
target_associated_path = os.path.join(target_path, new_associated_filename)
|
||||||
|
|
||||||
# Store metadata file path for special handling
|
# Store metadata file path for special handling
|
||||||
if file == f"{base_name}.metadata.json":
|
if file == f"{base_name}.metadata.json":
|
||||||
source_metadata = source_file_path
|
source_metadata = source_file_path
|
||||||
moved_metadata_path = os.path.join(target_path, file)
|
moved_metadata_path = target_associated_path
|
||||||
else:
|
else:
|
||||||
files_to_move.append((source_file_path, os.path.join(target_path, file)))
|
files_to_move.append((source_file_path, target_associated_path))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing files in {source_dir}: {e}")
|
logger.error(f"Error listing files in {source_dir}: {e}")
|
||||||
|
|
||||||
@@ -853,11 +889,11 @@ class ModelScanner:
|
|||||||
|
|
||||||
await self.update_single_model_cache(source_path, target_file, metadata)
|
await self.update_single_model_cache(source_path, target_file, metadata)
|
||||||
|
|
||||||
return True
|
return target_file
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||||
return False
|
return None
|
||||||
|
|
||||||
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Dict:
|
async def _update_metadata_paths(self, metadata_path: str, model_path: str) -> Dict:
|
||||||
"""Update file paths in metadata file"""
|
"""Update file paths in metadata file"""
|
||||||
@@ -866,12 +902,15 @@ class ModelScanner:
|
|||||||
metadata = json.load(f)
|
metadata = json.load(f)
|
||||||
|
|
||||||
metadata['file_path'] = model_path.replace(os.sep, '/')
|
metadata['file_path'] = model_path.replace(os.sep, '/')
|
||||||
|
# Update file_name to match the new filename
|
||||||
|
metadata['file_name'] = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
|
|
||||||
if 'preview_url' in metadata and metadata['preview_url']:
|
if 'preview_url' in metadata and metadata['preview_url']:
|
||||||
preview_dir = os.path.dirname(model_path)
|
preview_dir = os.path.dirname(model_path)
|
||||||
preview_name = os.path.splitext(os.path.basename(metadata['preview_url']))[0]
|
# Update preview filename to match the new base name
|
||||||
|
new_base_name = os.path.splitext(os.path.basename(model_path))[0]
|
||||||
preview_ext = os.path.splitext(metadata['preview_url'])[1]
|
preview_ext = os.path.splitext(metadata['preview_url'])[1]
|
||||||
new_preview_path = os.path.join(preview_dir, f"{preview_name}{preview_ext}")
|
new_preview_path = os.path.join(preview_dir, f"{new_base_name}{preview_ext}")
|
||||||
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
|
metadata['preview_url'] = new_preview_path.replace(os.sep, '/')
|
||||||
|
|
||||||
await MetadataManager.save_metadata(metadata_path, metadata)
|
await MetadataManager.save_metadata(metadata_path, metadata)
|
||||||
|
|||||||
@@ -83,6 +83,44 @@ class BaseModelMetadata:
|
|||||||
self.size = os.path.getsize(file_path)
|
self.size = os.path.getsize(file_path)
|
||||||
self.modified = os.path.getmtime(file_path)
|
self.modified = os.path.getmtime(file_path)
|
||||||
self.file_path = file_path.replace(os.sep, '/')
|
self.file_path = file_path.replace(os.sep, '/')
|
||||||
|
# Update file_name when file_path changes
|
||||||
|
self.file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_unique_filename(target_dir: str, base_name: str, extension: str, sha256_hash: str) -> str:
|
||||||
|
"""Generate a unique filename to avoid conflicts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_dir: Target directory path
|
||||||
|
base_name: Base filename without extension
|
||||||
|
extension: File extension including the dot
|
||||||
|
sha256_hash: SHA256 hash of the file for generating short hash
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Unique filename that doesn't conflict with existing files
|
||||||
|
"""
|
||||||
|
original_filename = f"{base_name}{extension}"
|
||||||
|
target_path = os.path.join(target_dir, original_filename)
|
||||||
|
|
||||||
|
# If no conflict, return original filename
|
||||||
|
if not os.path.exists(target_path):
|
||||||
|
return original_filename
|
||||||
|
|
||||||
|
# Generate short hash (first 4 characters of SHA256)
|
||||||
|
short_hash = sha256_hash[:4] if sha256_hash else "0000"
|
||||||
|
|
||||||
|
# Try with short hash suffix
|
||||||
|
unique_filename = f"{base_name}-{short_hash}{extension}"
|
||||||
|
unique_path = os.path.join(target_dir, unique_filename)
|
||||||
|
|
||||||
|
# If still conflicts, add incremental number
|
||||||
|
counter = 1
|
||||||
|
while os.path.exists(unique_path):
|
||||||
|
unique_filename = f"{base_name}-{short_hash}-{counter}{extension}"
|
||||||
|
unique_path = os.path.join(target_dir, unique_filename)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
return unique_filename
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LoraMetadata(BaseModelMetadata):
|
class LoraMetadata(BaseModelMetadata):
|
||||||
|
|||||||
@@ -982,6 +982,7 @@ class ModelRouteUtils:
|
|||||||
# Rename all files
|
# Rename all files
|
||||||
renamed_files = []
|
renamed_files = []
|
||||||
new_metadata_path = None
|
new_metadata_path = None
|
||||||
|
new_preview = None
|
||||||
|
|
||||||
for old_path, pattern in existing_files:
|
for old_path, pattern in existing_files:
|
||||||
# Get the file extension like .safetensors or .metadata.json
|
# Get the file extension like .safetensors or .metadata.json
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ export class BaseModelApiClient {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for WebSocket connection to establish
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
ws.onopen = resolve;
|
ws.onopen = resolve;
|
||||||
ws.onerror = reject;
|
ws.onerror = reject;
|
||||||
@@ -434,6 +435,7 @@ export class BaseModelApiClient {
|
|||||||
throw new Error('Failed to fetch metadata');
|
throw new Error('Failed to fetch metadata');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for the operation to complete via WebSocket
|
||||||
await operationComplete;
|
await operationComplete;
|
||||||
|
|
||||||
resetAndReload(false);
|
resetAndReload(false);
|
||||||
@@ -749,7 +751,10 @@ export class BaseModelApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
return result.new_file_path;
|
return {
|
||||||
|
original_file_path: result.original_file_path || filePath,
|
||||||
|
new_file_path: result.new_file_path
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -785,7 +790,6 @@ export class BaseModelApiClient {
|
|||||||
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
|
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let successFilePaths = [];
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.failure_count > 0) {
|
if (result.failure_count > 0) {
|
||||||
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
|
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
|
||||||
@@ -793,7 +797,7 @@ export class BaseModelApiClient {
|
|||||||
const failedFiles = result.results
|
const failedFiles = result.results
|
||||||
.filter(r => !r.success)
|
.filter(r => !r.success)
|
||||||
.map(r => {
|
.map(r => {
|
||||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
const fileName = r.original_file_path.substring(r.original_file_path.lastIndexOf('/') + 1);
|
||||||
return `${fileName}: ${r.message}`;
|
return `${fileName}: ${r.message}`;
|
||||||
});
|
});
|
||||||
if (failedFiles.length > 0) {
|
if (failedFiles.length > 0) {
|
||||||
@@ -805,13 +809,12 @@ export class BaseModelApiClient {
|
|||||||
} else {
|
} else {
|
||||||
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
|
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
|
||||||
}
|
}
|
||||||
successFilePaths = result.results
|
|
||||||
.filter(r => r.success)
|
// Return the results array with original_file_path and new_file_path
|
||||||
.map(r => r.path);
|
return result.results || [];
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
|
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||||
}
|
}
|
||||||
return successFilePaths;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkDeleteModels(filePaths) {
|
async bulkDeleteModels(filePaths) {
|
||||||
|
|||||||
@@ -177,40 +177,48 @@ class MoveManager {
|
|||||||
try {
|
try {
|
||||||
if (this.bulkFilePaths) {
|
if (this.bulkFilePaths) {
|
||||||
// Bulk move mode
|
// Bulk move mode
|
||||||
const movedFilePaths = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath);
|
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath);
|
||||||
|
|
||||||
// Update virtual scroller if in active folder view
|
// Update virtual scroller if in active folder view
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
if (pageState.activeFolder !== null && state.virtualScroller) {
|
if (pageState.activeFolder !== null && state.virtualScroller) {
|
||||||
// Remove only successfully moved items
|
// Remove items that were successfully moved
|
||||||
movedFilePaths.forEach(newFilePath => {
|
results.forEach(result => {
|
||||||
// Find original filePath by matching filename
|
if (result.success) {
|
||||||
const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1);
|
state.virtualScroller.removeItemByFilePath(result.original_file_path);
|
||||||
const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename));
|
|
||||||
if (originalFilePath) {
|
|
||||||
state.virtualScroller.removeItemByFilePath(originalFilePath);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Update the model cards' filepath in the DOM
|
// Update the model cards' filepath and filename in the DOM
|
||||||
movedFilePaths.forEach(newFilePath => {
|
results.forEach(result => {
|
||||||
const filename = newFilePath.substring(newFilePath.lastIndexOf('/') + 1);
|
if (result.success && result.new_file_path !== result.original_file_path) {
|
||||||
const originalFilePath = this.bulkFilePaths.find(fp => fp.endsWith('/' + filename));
|
const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
|
||||||
if (originalFilePath) {
|
const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.'));
|
||||||
state.virtualScroller.updateSingleItem(originalFilePath, {file_path: newFilePath});
|
|
||||||
|
state.virtualScroller.updateSingleItem(result.original_file_path, {
|
||||||
|
file_path: result.new_file_path,
|
||||||
|
file_name: baseFileName
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Single move mode
|
// Single move mode
|
||||||
const newFilePath = await apiClient.moveSingleModel(this.currentFilePath, targetPath);
|
const result = await apiClient.moveSingleModel(this.currentFilePath, targetPath);
|
||||||
|
|
||||||
const pageState = getCurrentPageState();
|
const pageState = getCurrentPageState();
|
||||||
if (newFilePath) {
|
if (result && result.new_file_path) {
|
||||||
if (pageState.activeFolder !== null && state.virtualScroller) {
|
if (pageState.activeFolder !== null && state.virtualScroller) {
|
||||||
state.virtualScroller.removeItemByFilePath(this.currentFilePath);
|
state.virtualScroller.removeItemByFilePath(this.currentFilePath);
|
||||||
} else {
|
} else if (result.new_file_path !== this.currentFilePath) {
|
||||||
state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath});
|
// Update both file_path and file_name if they changed
|
||||||
|
const newFileName = result.new_file_path.substring(result.new_file_path.lastIndexOf('/') + 1);
|
||||||
|
const baseFileName = newFileName.substring(0, newFileName.lastIndexOf('.'));
|
||||||
|
|
||||||
|
state.virtualScroller.updateSingleItem(this.currentFilePath, {
|
||||||
|
file_path: result.new_file_path,
|
||||||
|
file_name: baseFileName
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user