feat: Enhance model moving functionality with improved error handling and unique filename generation

This commit is contained in:
Will Miao
2025-08-25 13:08:35 +08:00
parent 1814f83bee
commit 919fed05c5
6 changed files with 159 additions and 60 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
});
} }
} }
} }