diff --git a/core/downloader.py b/core/downloader.py index fa0bb1a..70682be 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -6,6 +6,7 @@ Handles downloading models from various sources with progress tracking. import os import json +import hashlib import threading import time import requests @@ -304,6 +305,78 @@ def _find_metadata_file_info( return {} +def _normalize_sha256(value: Any) -> str: + text = str(value or "").strip().lower() + for prefix in ("sha256:", "sha256="): + if text.startswith(prefix): + text = text[len(prefix):].strip() + break + if len(text) == 64 and all(char in "0123456789abcdef" for char in text): + return text + return "" + + +def _extract_expected_sha256(metadata: Optional[Dict[str, Any]]) -> str: + source = metadata if isinstance(metadata, dict) else {} + details = _as_dict(source.get("civitai_details") or source.get("details")) + selected_version = _as_dict( + source.get("selected_version") or details.get("selected_version") + ) + path_metadata = _as_dict(source.get("path_metadata")) + filename = _first_present( + source.get("filename"), + path_metadata.get("filename"), + ) + file_info = _find_metadata_file_info(source, selected_version, str(filename)) + hashes = _as_dict(_first_present(file_info.get("hashes"), source.get("hashes"))) + return _normalize_sha256( + _first_present( + source.get("sha256"), + source.get("hash"), + file_info.get("sha256"), + file_info.get("hash"), + hashes.get("SHA256"), + hashes.get("sha256"), + ) + ) + + +def read_completed_metadata_sha256(file_path: str) -> str: + """Read a trusted SHA256 from a LoRA Manager-compatible sidecar if available.""" + metadata_path = get_metadata_sidecar_path(file_path) + if not os.path.exists(metadata_path): + return "" + + try: + with open(metadata_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception as e: + log_warn(f"Could not read metadata sidecar for SHA256 check: {metadata_path} ({e})") + return "" + + if not isinstance(payload, dict): + return "" + + hash_status = str(payload.get("hash_status") or "completed").strip().lower() + if hash_status != "completed": + log_debug( + f"Skipping metadata SHA256 for {metadata_path}: hash_status={hash_status}" + ) + return "" + + return _normalize_sha256(payload.get("sha256") or payload.get("hash")) + + +def calculate_file_sha256(file_path: str) -> str: + """Calculate SHA256 for an existing local file.""" + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as handle: + for chunk in iter(lambda: handle.read(CHUNK_SIZE), b""): + if chunk: + sha256_hash.update(chunk) + return sha256_hash.hexdigest() + + def build_lora_manager_metadata( dest_path: str, metadata: Optional[Dict[str, Any]] = None, @@ -1004,6 +1077,98 @@ def download_model( # Check if file already exists if os.path.exists(dest_path): + expected_sha256 = _extract_expected_sha256(metadata) + if expected_sha256: + metadata_sha256 = read_completed_metadata_sha256(dest_path) + sha256_source = "metadata" + existing_sha256 = metadata_sha256 + if metadata_sha256: + if metadata_sha256 == expected_sha256: + log_info(f"File exists, metadata SHA256 matches: {dest_path}") + else: + log_info( + "File exists, metadata SHA256 differs from source; " + f"verifying file content: {dest_path}" + ) + existing_sha256 = "" + + try: + if not existing_sha256: + sha256_source = "file" + log_info(f"File exists, verifying SHA256: {dest_path}") + existing_sha256 = calculate_file_sha256(dest_path) + except Exception as e: + error_msg = ( + f"File already exists and its SHA256 could not be verified: {dest_path}" + ) + log_warn(f"{error_msg} ({e})") + return { + "success": False, + "download_id": download_id, + "error": error_msg, + "path": dest_path, + } + + if existing_sha256 == expected_sha256: + message = "This model is already downloaded and matches the source hash." + metadata_path = get_metadata_sidecar_path(dest_path) + if not os.path.exists(metadata_path): + metadata_path = write_lora_manager_metadata( + dest_path, + metadata or {}, + category, + url, + ) or "" + size = os.path.getsize(dest_path) + with download_lock: + if download_id in download_progress: + download_progress[download_id].update( + { + "status": "completed", + "progress": 100, + "total_size": size, + "downloaded": size, + "speed": 0, + "path": dest_path, + "directory": os.path.dirname(dest_path), + "error": None, + "already_exists": True, + "message": message, + "sha256": existing_sha256, + "expected_sha256": expected_sha256, + "sha256_source": sha256_source, + } + ) + if metadata_path: + download_progress[download_id]["metadata_path"] = metadata_path + log_info(f"{message} Path: {dest_path}") + return { + "success": True, + "download_id": download_id, + "path": dest_path, + "size": size, + "already_exists": True, + "message": message, + "metadata_path": metadata_path, + "sha256_source": sha256_source, + } + + error_msg = ( + "File already exists, but its SHA256 does not match the selected " + f"source: {dest_path}" + ) + log_warn( + f"{error_msg} (existing={existing_sha256}, expected={expected_sha256})" + ) + return { + "success": False, + "download_id": download_id, + "error": error_msg, + "path": dest_path, + "existing_sha256": existing_sha256, + "expected_sha256": expected_sha256, + } + return { "success": False, "download_id": download_id, diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 68df6b4..35a0143 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -970,7 +970,7 @@ export const resolveDownloadMethods = { if (noneEl) noneEl.textContent = `${stats.none} no match`; }, - async refreshAfterDownload(missing, downloadedFilename, { progressDiv = null, downloadBtn = null, category = '', downloadPath = '', downloadDirectory = '' } = {}) { + async refreshAfterDownload(missing, downloadedFilename, { progressDiv = null, downloadBtn = null, category = '', downloadPath = '', downloadDirectory = '', alreadyExists = false } = {}) { try { const matches = await this.refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, { progressDiv, @@ -985,8 +985,8 @@ export const resolveDownloadMethods = { || (downloadedLower && matchFilename.toLowerCase() === downloadedLower); }); const message = perfectMatch?.model - ? 'Download complete. Exact local match is ready.' - : 'Download complete. Local matches updated.'; + ? (alreadyExists ? 'This model is already downloaded. Exact local match is ready.' : 'Download complete. Exact local match is ready.') + : (alreadyExists ? 'This model is already downloaded. Local matches updated.' : 'Download complete. Local matches updated.'); const snapshot = this.rememberDownloadSnapshotForMissing(missing, { downloadId: null, category, @@ -997,7 +997,8 @@ export const resolveDownloadMethods = { status: 'completed', filename: downloadedFilename, path: downloadPath, - directory: downloadDirectory + directory: downloadDirectory, + already_exists: alreadyExists }, status: 'completed', message, @@ -1026,7 +1027,9 @@ export const resolveDownloadMethods = { directory: downloadDirectory }, status: 'refresh_error', - message: `Downloaded, but local re-check failed: ${error.message}`, + message: alreadyExists + ? `Already downloaded, but local re-check failed: ${error.message}` + : `Downloaded, but local re-check failed: ${error.message}`, type: 'warning', isActive: false }); @@ -1039,12 +1042,16 @@ export const resolveDownloadMethods = { downloadBtn.disabled = false; downloadBtn.textContent = 'Link manually'; } - this.showNotification('Downloaded, but local re-check failed: ' + error.message, 'warning', { - contextMenuModel: this.getDownloadFolderContext( - { filename: downloadedFilename, path: downloadPath, directory: downloadDirectory }, - { category, downloadPath, downloadDirectory, filename: downloadedFilename } - ) - }); + this.showNotification( + `${alreadyExists ? 'Already downloaded' : 'Downloaded'}, but local re-check failed: ${error.message}`, + 'warning', + { + contextMenuModel: this.getDownloadFolderContext( + { filename: downloadedFilename, path: downloadPath, directory: downloadDirectory }, + { category, downloadPath, downloadDirectory, filename: downloadedFilename } + ) + } + ); } }, @@ -1228,9 +1235,12 @@ export const resolveDownloadMethods = { setTimeout(() => this.pollDownloadProgress(downloadId), 1000); } else if (progress.status === 'completed') { + const alreadyExists = Boolean(progress.already_exists); const completedSnapshot = this.rememberDownloadUiState(downloadId, info, progress, { status: 'completed_checking', - message: 'Download complete. Checking local matches...', + message: alreadyExists + ? (progress.message || 'This model is already downloaded. Checking local matches...') + : 'Download complete. Checking local matches...', type: 'success', isActive: false }); @@ -1238,7 +1248,9 @@ export const resolveDownloadMethods = { delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); - this.showNotification(`Downloaded: ${progress.filename}`, 'success', { + this.showNotification(alreadyExists + ? `Already downloaded: ${progress.filename}` + : `Downloaded: ${progress.filename}`, 'success', { contextMenuModel: downloadFolderContext }); @@ -1251,7 +1263,8 @@ export const resolveDownloadMethods = { downloadBtn: currentElements.downloadBtn, category, downloadPath: progress.path || info.downloadPath || '', - downloadDirectory: progress.directory || info.downloadDirectory || '' + downloadDirectory: progress.directory || info.downloadDirectory || '', + alreadyExists }); }, 500);