diff --git a/core/download/api.py b/core/download/api.py index 1792e63..7a747da 100644 --- a/core/download/api.py +++ b/core/download/api.py @@ -230,6 +230,12 @@ context._run_huggingface_xet_transfer = _bind( ) context.download_file_with_aria2 = _bind(aria2_backend.download_file_with_aria2) +context.verify_download_sha256_with_progress = _bind( + aria2_backend.verify_download_sha256_with_progress +) +context._verify_download_sha256_with_progress = ( + context.verify_download_sha256_with_progress +) context.start_background_sha256_verification = _bind( aria2_backend.start_background_sha256_verification ) diff --git a/core/download/aria2_backend.py b/core/download/aria2_backend.py index 2c0d39d..9c2a6fa 100644 --- a/core/download/aria2_backend.py +++ b/core/download/aria2_backend.py @@ -858,6 +858,7 @@ def download_file_with_aria2( metadata: Optional[Dict[str, Any]] = None, category: str = "", *, + expected_sha256: Optional[str] = None, dependencies: Any = None, ) -> Dict[str, Any]: """Download a file with an aria2c JSON-RPC process.""" @@ -872,7 +873,9 @@ def download_file_with_aria2( } start_time = facade.time.time() filename = facade.get_filename_from_path(dest_path) - expected_sha256 = facade._extract_expected_sha256(metadata) + expected_sha256 = ( + expected_sha256 or facade._extract_expected_sha256(metadata) + ) max_url_recovery = getattr( facade, "MAX_ARIA2_URL_RECOVERY_ATTEMPTS", @@ -1144,6 +1147,126 @@ def download_file_with_aria2( if facade.os.path.exists(completed_path) else downloaded ) + clean_expected = str(expected_sha256 or "").lower().strip() + + if clean_expected and facade.os.path.isfile(completed_path): + with facade.download_lock: + if download_id in facade.download_progress: + facade.download_progress[download_id].update( + { + "status": "verifying", + "progress": 0, + "downloaded": 0, + "total_size": size, + "speed": 0, + "path": completed_path, + "directory": facade.os.path.dirname(completed_path), + "expected_sha256": clean_expected, + } + ) + log.info( + f"aria2 transfer complete for {filename}; verifying SHA256 integrity..." + ) + + verify_fn = getattr( + facade, + "verify_download_sha256_with_progress", + None, + ) or verify_download_sha256_with_progress + + is_match, actual_sha256, is_cancelled = verify_fn( + completed_path, + clean_expected, + download_id=download_id, + size=size, + dependencies=facade, + ) + + if is_cancelled or download_id in facade.cancelled_downloads: + facade._delete_partial_download_files(completed_path) + facade.cancelled_downloads.discard(download_id) + with facade.download_lock: + if download_id in facade.download_progress: + facade.download_progress[download_id]["status"] = "cancelled" + facade.download_progress[download_id]["speed"] = 0 + result["error"] = "Download cancelled" + return result + + if not is_match: + log.error( + f"✗ SHA256 mismatch detected for {filename}! " + f"Expected {clean_expected}, got {actual_sha256}" + ) + bad_path = f"{completed_path}.badsha" + try: + facade.os.replace(completed_path, bad_path) + except OSError as exc: + log.warning( + f"Could not preserve SHA256-mismatched download at {bad_path}: {exc}" + ) + bad_path = "" + + error_msg = f"SHA256 mismatch: expected {clean_expected}, got {actual_sha256}" + if bad_path: + error_msg += f"; file kept at {bad_path}" + sidecar_model_path = ( + bad_path + if facade.os.path.exists(completed_path) + else completed_path + ) + try: + payload = facade.build_model_resolver_metadata( + sidecar_model_path, + {"sha256": actual_sha256, "size": size}, + category, + ) + payload.update( + { + "expected_sha256": clean_expected, + "sha256_verified": False, + } + ) + sidecar_meta_path = ( + facade.get_model_resolver_sidecar_path( + sidecar_model_path + ) + ) + facade.write_json_atomic( + sidecar_meta_path, payload, indent=2 + ) + except Exception as exc: + log.warning( + f"Could not save metadata for {bad_path}: {exc}" + ) + + with facade.download_lock: + if download_id in facade.download_progress: + facade.download_progress[download_id].update( + { + "status": "error", + "error": error_msg, + "speed": 0, + "sha256": actual_sha256, + "actual_sha256": actual_sha256, + "expected_sha256": clean_expected, + "sha256_verified": False, + "path": bad_path or completed_path, + } + ) + + result.update( + { + "success": False, + "error": error_msg, + "path": bad_path or completed_path, + "size": size, + "sha256": actual_sha256, + "expected_sha256": clean_expected, + "sha256_verified": False, + } + ) + return result + metadata_path = facade.write_model_resolver_metadata( completed_path, metadata or {}, @@ -1151,6 +1274,13 @@ def download_file_with_aria2( url, create_preview=True, ) + if metadata_path and facade.os.path.isfile(metadata_path) and clean_expected: + meta = facade.read_json_safe(metadata_path, {}) + if isinstance(meta, dict): + meta["sha256_verified"] = True + meta["sha256"] = clean_expected + facade.write_json_atomic(metadata_path, meta, indent=2) + with facade.download_lock: facade.download_progress[download_id].update( { @@ -1161,6 +1291,9 @@ def download_file_with_aria2( "speed": 0, "path": completed_path, "directory": facade.os.path.dirname(completed_path), + "sha256_verified": True if clean_expected else None, + "sha256": clean_expected or None, + "expected_sha256": clean_expected or None, } ) if metadata_path: @@ -1173,6 +1306,8 @@ def download_file_with_aria2( "path": completed_path, "size": size, "metadata_path": metadata_path, + "sha256_verified": True if clean_expected else None, + "sha256": clean_expected or None, } ) guard_fn = getattr(facade, "get_connectivity_guard", None) @@ -1187,15 +1322,6 @@ def download_file_with_aria2( ) facade.invalidate_model_caches() - if expected_sha256: - start_background_sha256_verification( - completed_path, - expected_sha256, - download_id=download_id, - metadata_path=metadata_path, - dependencies=facade, - ) - return result if state == "error": @@ -1315,6 +1441,86 @@ def download_file_with_aria2( facade._schedule_aria2_idle_stop() +def verify_download_sha256_with_progress( + completed_path: str, + expected_sha256: str, + *, + download_id: str = "", + size: int = 0, + dependencies: Any = None, +) -> tuple[bool, str, bool]: + """Verify downloaded model SHA256 while streaming progress to download_progress. + + Returns (is_match, actual_sha256, is_cancelled). + """ + facade = require_download_dependencies(dependencies, "aria2 backend") + clean_expected = str(expected_sha256 or "").lower().strip() + if not clean_expected or not facade.os.path.isfile(completed_path): + return True, "", False + + filename = facade.get_filename_from_path(completed_path) + file_size = ( + size + or ( + facade.os.path.getsize(completed_path) + if facade.os.path.isfile(completed_path) + else 0 + ) + ) + hasher = facade.hashlib.sha256() + + start_t = facade.time.time() + last_ui_update = start_t + last_cli_log = start_t + verified_bytes = 0 + last_verified_bytes = 0 + + with open(completed_path, "rb") as file_handle: + while True: + if download_id and download_id in facade.cancelled_downloads: + return False, "", True + + chunk = file_handle.read(4 * 1024 * 1024) + if not chunk: + break + hasher.update(chunk) + verified_bytes += len(chunk) + + now = facade.time.time() + if now - last_ui_update >= 0.25: + time_delta = now - last_ui_update + bytes_delta = verified_bytes - last_verified_bytes + verify_speed = int(bytes_delta / time_delta) if time_delta > 0 else 0 + last_ui_update = now + last_verified_bytes = verified_bytes + pct = int((verified_bytes / file_size) * 100) if file_size > 0 else 0 + + if download_id: + with facade.download_lock: + if download_id in facade.download_progress: + facade.download_progress[download_id].update( + { + "status": "verifying", + "progress": pct, + "downloaded": verified_bytes, + "total_size": file_size, + "speed": verify_speed, + } + ) + + if now - last_cli_log >= facade.CLI_LOG_INTERVAL: + last_cli_log = now + pct = int((verified_bytes / file_size) * 100) if file_size > 0 else 0 + log.info( + f"Verifying SHA256 for {filename}: " + f"{facade.format_bytes(verified_bytes)} / {facade.format_bytes(file_size)} " + f"({pct}%)" + ) + + actual_sha256 = hasher.hexdigest().lower() + return (actual_sha256 == clean_expected), actual_sha256, False + + def start_background_sha256_verification( completed_path: str, expected_sha256: str, @@ -1333,26 +1539,29 @@ def start_background_sha256_verification( filename = facade.get_filename_from_path(completed_path) log.debug(f"Starting background SHA256 verification for {filename}...") try: - hasher = facade.hashlib.sha256() - with open(completed_path, "rb") as file_handle: - while True: - chunk = file_handle.read(4 * 1024 * 1024) - if not chunk: - break - hasher.update(chunk) - actual_sha256 = hasher.hexdigest().lower() + is_match, actual_sha256, is_cancelled = verify_download_sha256_with_progress( + completed_path, + expected_sha256, + download_id=download_id, + dependencies=facade, + ) + if is_cancelled: + return - if actual_sha256 == clean_expected: + target_meta_path = ( + metadata_path + or facade.get_model_resolver_sidecar_path(completed_path) + ) + if is_match: log.info(f"✓ Background SHA256 verified for: {filename}") if download_id: with facade.download_lock: if download_id in facade.download_progress: + facade.download_progress[download_id]["status"] = "completed" + facade.download_progress[download_id]["progress"] = 100 + facade.download_progress[download_id]["speed"] = 0 facade.download_progress[download_id]["sha256_verified"] = True facade.download_progress[download_id]["sha256"] = actual_sha256 - target_meta_path = ( - metadata_path - or facade.get_model_resolver_sidecar_path(completed_path) - ) if target_meta_path and facade.os.path.isfile(target_meta_path): meta = facade.read_json_safe(target_meta_path, {}) if isinstance(meta, dict): @@ -1364,18 +1573,21 @@ def start_background_sha256_verification( f"✗ SHA256 mismatch detected for {filename}! " f"Expected {clean_expected}, got {actual_sha256}" ) + bad_path = f"{completed_path}.badsha" + try: + facade.os.replace(completed_path, bad_path) + except OSError: + bad_path = "" + error_msg = f"SHA256 mismatch: expected {clean_expected}, got {actual_sha256}" + if bad_path: + error_msg += f"; file kept at {bad_path}" if download_id: with facade.download_lock: if download_id in facade.download_progress: + facade.download_progress[download_id]["status"] = "error" facade.download_progress[download_id]["sha256_verified"] = False facade.download_progress[download_id]["actual_sha256"] = actual_sha256 - facade.download_progress[download_id]["error"] = ( - f"SHA256 mismatch: expected {clean_expected}, got {actual_sha256}" - ) - target_meta_path = ( - metadata_path - or facade.get_model_resolver_sidecar_path(completed_path) - ) + facade.download_progress[download_id]["error"] = error_msg if target_meta_path and facade.os.path.isfile(target_meta_path): meta = facade.read_json_safe(target_meta_path, {}) if isinstance(meta, dict): diff --git a/tests/test_downloader_boundaries.py b/tests/test_downloader_boundaries.py index aa04073..9363042 100644 --- a/tests/test_downloader_boundaries.py +++ b/tests/test_downloader_boundaries.py @@ -605,6 +605,139 @@ class DownloaderBoundaryTests(unittest.TestCase): self.assertEqual(result["error"], "Download cancelled") self.assertEqual(mock_rpc.call_count, 0) + def test_aria2_download_sha256_verification_success(self): + import hashlib + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "model_verified.safetensors") + content = b"valid model file content for aria2 verification" + with open(dest_path, "wb") as f: + f.write(content) + + expected_sha = hashlib.sha256(content).hexdigest() + download_id = "test-aria2-verify-success" + + mock_resolve = MagicMock(return_value=("https://example.com/model.safetensors", {})) + mock_rpc = MagicMock(return_value="gid-verify-1") + mock_status = MagicMock(return_value={ + "status": "complete", + "completedLength": str(len(content)), + "totalLength": str(len(content)), + }) + + with patch.object(downloader, "_ensure_aria2_daemon"), \ + patch.object(downloader, "_resolve_download_url_for_aria2", mock_resolve), \ + patch.object(downloader, "_aria2_rpc", mock_rpc), \ + patch.object(downloader, "_aria2_tell_status", mock_status), \ + patch.object(downloader, "write_model_resolver_metadata", return_value=""), \ + patch.object(downloader, "_schedule_aria2_idle_stop"), \ + patch.object(downloader, "invalidate_model_caches"): + + result = download_file_with_aria2( + "https://example.com/model.safetensors", + dest_path, + download_id, + expected_sha256=expected_sha, + dependencies=downloader, + ) + + self.assertTrue(result["success"]) + self.assertEqual(dest_path, result["path"]) + self.assertTrue(os.path.isfile(dest_path)) + with downloader.download_lock: + progress = downloader.download_progress.get(download_id, {}) + self.assertEqual("completed", progress.get("status")) + self.assertTrue(progress.get("sha256_verified")) + self.assertEqual(expected_sha, progress.get("sha256")) + + def test_aria2_download_sha256_verification_mismatch(self): + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "model_corrupt.safetensors") + content = b"corrupted model file content" + with open(dest_path, "wb") as f: + f.write(content) + + expected_sha = "f" * 64 + download_id = "test-aria2-verify-mismatch" + + mock_resolve = MagicMock(return_value=("https://example.com/model.safetensors", {})) + mock_rpc = MagicMock(return_value="gid-verify-2") + mock_status = MagicMock(return_value={ + "status": "complete", + "completedLength": str(len(content)), + "totalLength": str(len(content)), + }) + + with patch.object(downloader, "_ensure_aria2_daemon"), \ + patch.object(downloader, "_resolve_download_url_for_aria2", mock_resolve), \ + patch.object(downloader, "_aria2_rpc", mock_rpc), \ + patch.object(downloader, "_aria2_tell_status", mock_status), \ + patch.object(downloader, "write_model_resolver_metadata", return_value=""), \ + patch.object(downloader, "_schedule_aria2_idle_stop"), \ + patch.object(downloader, "invalidate_model_caches"): + + result = download_file_with_aria2( + "https://example.com/model.safetensors", + dest_path, + download_id, + expected_sha256=expected_sha, + dependencies=downloader, + ) + + self.assertFalse(result["success"]) + self.assertIn("sha256 mismatch", result["error"].lower()) + self.assertFalse(os.path.exists(dest_path)) + bad_path = f"{dest_path}.badsha" + self.assertTrue(os.path.isfile(bad_path)) + + with downloader.download_lock: + progress = downloader.download_progress.get(download_id, {}) + self.assertEqual("error", progress.get("status")) + self.assertFalse(progress.get("sha256_verified")) + self.assertIn("mismatch", progress.get("error", "").lower()) + + def test_aria2_download_sha256_verification_cancelled(self): + with tempfile.TemporaryDirectory() as temp_dir: + dest_path = os.path.join(temp_dir, "model_cancelled.safetensors") + content = b"content being verified" + with open(dest_path, "wb") as f: + f.write(content) + + download_id = "test-aria2-verify-cancel" + mock_resolve = MagicMock(return_value=("https://example.com/model.safetensors", {})) + mock_rpc = MagicMock(return_value="gid-verify-3") + mock_status = MagicMock(return_value={ + "status": "complete", + "completedLength": str(len(content)), + "totalLength": str(len(content)), + }) + + def mock_verify(*args, **kwargs): + return False, "", True + + with patch.object(downloader, "_ensure_aria2_daemon"), \ + patch.object(downloader, "_resolve_download_url_for_aria2", mock_resolve), \ + patch.object(downloader, "_aria2_rpc", mock_rpc), \ + patch.object(downloader, "_aria2_tell_status", mock_status), \ + patch.object(downloader, "verify_download_sha256_with_progress", mock_verify), \ + patch.object(downloader, "write_model_resolver_metadata", return_value=""), \ + patch.object(downloader, "_schedule_aria2_idle_stop"), \ + patch.object(downloader, "invalidate_model_caches"): + + result = download_file_with_aria2( + "https://example.com/model.safetensors", + dest_path, + download_id, + expected_sha256="expected_sha", + dependencies=downloader, + ) + + self.assertFalse(result["success"]) + self.assertEqual("Download cancelled", result["error"]) + self.assertFalse(os.path.exists(dest_path)) + with downloader.download_lock: + progress = downloader.download_progress.get(download_id, {}) + self.assertEqual("cancelled", progress.get("status")) + def test_parse_retry_after(self): self.assertEqual(parse_retry_after("120"), 120) self.assertEqual(parse_retry_after("0"), 1) diff --git a/web/css/resolver-shell.css b/web/css/resolver-shell.css index 40b6be7..8c56d42 100644 --- a/web/css/resolver-shell.css +++ b/web/css/resolver-shell.css @@ -784,6 +784,17 @@ box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); } +.mr-download-queue-status.mr-download-queue-status-verifying { + color: var(--mr-color-code-blue, #38bdf8); + background: rgba(56, 189, 248, 0.16); + border-color: rgba(56, 189, 248, 0.34); +} + +.mr-download-queue-progress .mr-progress-fill.mr-progress-fill-verifying { + background: var(--mr-color-code-blue, #38bdf8); + box-shadow: 0 0 10px rgba(56, 189, 248, 0.3); +} + .mr-download-queue-stat.mr-download-queue-stat-error { max-width: 220px; overflow: hidden; diff --git a/web/resolver/actions/download_progress_methods.js b/web/resolver/actions/download_progress_methods.js index 3ce38de..9574d88 100644 --- a/web/resolver/actions/download_progress_methods.js +++ b/web/resolver/actions/download_progress_methods.js @@ -4,13 +4,14 @@ export const downloadProgressMethods = { */ getDownloadProgressPollDelay(progress = {}, info = {}) { if (progress.status === 'paused') return 1500; + if (progress.status === 'verifying') return 300; const backend = String( progress.download_backend || info.downloadBackend || info.download_backend || '' ).toLowerCase(); return backend === 'huggingface_xet' ? 200 : 1000; }, - getDownloadProgressStatusLabel(status = '', percent = 0, isFinalizing = false) { + getDownloadProgressStatusLabel(status = '', percent = 0, isFinalizing = false, errorMessage = '') { if (status === 'downloading') { if (isFinalizing) return 'Finalizing'; const percentLabel = this.formatDownloadPercent?.(percent) ?? String(Math.round(percent)); @@ -18,7 +19,13 @@ export const downloadProgressMethods = { } if (status === 'starting') return 'Starting'; if (status === 'paused') return 'Paused'; - if (status === 'error' || status === 'refresh_error') return 'Interrupted'; + if (status === 'verifying') return 'Verifying SHA-256'; + if (status === 'error' || status === 'refresh_error') { + if (String(errorMessage || '').toLowerCase().includes('sha256')) { + return 'SHA-256 mismatch'; + } + return 'Interrupted'; + } if (status === 'completed_checking') return 'Checking'; return String(status || '').replace(/_/g, ' ') || 'Download'; }, @@ -28,6 +35,7 @@ export const downloadProgressMethods = { return status === 'starting' || status === 'downloading' || status === 'paused' + || status === 'verifying' || (isActive && !terminalStatuses.has(status)); }, }; diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index b129972..acced26 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -1231,13 +1231,28 @@ export const queueMethods = { const backend = String(progress.download_backend || info.downloadBackend || '').toLowerCase(); const isAria2 = backend === 'aria2'; const isPaused = status === 'paused'; + const isVerifying = status === 'verifying'; const isError = status === 'error' || status === 'refresh_error'; + const errorMessage = progress.error || info.statusSnapshot?.message || ''; + const isShaMismatch = isError && String(errorMessage).toLowerCase().includes('sha256'); const statusLabel = status === 'downloading' ? (presentation.isFinalizing ? 'Finalizing' : '') - : (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : (isError ? 'Interrupted' : status.replace(/_/g, ' ')))); - const sizeTextBase = presentation.isFinalizing && logicalTotal - ? `Finalizing file: ${logicalDownloaded} / ${logicalTotal}` - : (presentation.totalSize ? `${downloaded} / ${total}` : downloaded); + : (isVerifying + ? 'Verifying SHA-256' + : (status === 'starting' + ? 'Starting' + : (isPaused + ? 'Paused' + : (isShaMismatch + ? 'SHA-256 mismatch' + : (isError + ? 'Interrupted' + : status.replace(/_/g, ' ')))))); + const sizeTextBase = isVerifying + ? `Verifying integrity: ${downloaded} / ${total}` + : (presentation.isFinalizing && logicalTotal + ? `Finalizing file: ${logicalDownloaded} / ${logicalTotal}` + : (presentation.totalSize ? `${downloaded} / ${total}` : downloaded)); const sizeText = presentation.totalSize ? `${sizeTextBase} (${percentLabel}%)` : sizeTextBase; const speed = Number(progress.speed) || 0; const speedText = speed > 0 ? `${this.formatBytes(speed)}/s` : ''; @@ -1268,9 +1283,11 @@ export const queueMethods = { html += `
`; html += `${this.escapeHtml(filename)}`; if (statusLabel) { - const errorMessage = progress.error || info.statusSnapshot?.message || ''; const errorTooltip = isError && errorMessage ? ` data-tooltip="${this.escapeHtml(errorMessage)}"` : ''; - html += `${this.escapeHtml(statusLabel)}`; + const statusClass = isError + ? ' mr-download-queue-status-error' + : (isVerifying ? ' mr-download-queue-status-verifying' : ''); + html += `${this.escapeHtml(statusLabel)}`; } html += `
`; html += `
`; @@ -1281,7 +1298,7 @@ export const queueMethods = { isPaused ? 'mr-download-queue-resume' : 'mr-download-queue-pause', isPaused ? 'Resume download' : 'Pause download', isPaused ? 'play' : 'pause', - !isAria2 + !isAria2 || isVerifying ); } html += actionButton('mr-download-queue-cancel', 'Cancel download', 'x'); @@ -1293,12 +1310,14 @@ export const queueMethods = { html += `
`; html += ``; html += `
`; - html += `
`; + html += `
`; html += `
`; html += `${this.escapeHtml(sizeText)}`; html += ``; if (speedText && !isError) { - html += `${renderIcon('download', 'mr-download-queue-stat-icon')}${this.escapeHtml(speedText)}`; + const speedIcon = isVerifying ? 'hash' : 'download'; + const speedTooltip = isVerifying ? 'Hashing speed' : 'Download speed'; + html += `${renderIcon(speedIcon, 'mr-download-queue-stat-icon')}${this.escapeHtml(speedText)}`; } if (etaText && !isError) { html += `${this.escapeHtml(etaText)}`; diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 1ad1676..f3a6a47 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -1291,9 +1291,11 @@ export const resolveDownloadMethods = { : ''; const label = displayProgress.isFinalizing ? 'Finalizing download...' - : (status === 'paused' - ? `Paused ${percentLabel}`.trim() - : (percentLabel ? `Downloading ${percentLabel}` : 'Starting download...')); + : (status === 'verifying' + ? (percentLabel ? `Verifying SHA ${percentLabel}` : 'Verifying SHA-256...') + : (status === 'paused' + ? `Paused ${percentLabel}`.trim() + : (percentLabel ? `Downloading ${percentLabel}` : 'Starting download...'))); if (downloadBtn.classList.contains('search-download-btn')) { this.ensureSearchDownloadButtonIcon(downloadBtn); downloadBtn.setAttribute('data-tooltip', label); @@ -1301,9 +1303,11 @@ export const resolveDownloadMethods = { } else { downloadBtn.textContent = displayProgress.isFinalizing ? 'Finalizing...' - : (status === 'paused' - ? 'Paused' - : (Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...')); + : (status === 'verifying' + ? (Number.isFinite(percent) && percent > 0 ? `SHA ${Math.round(percent)}%` : 'Verifying...') + : (status === 'paused' + ? 'Paused' + : (Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...'))); } } else if (status === 'cancelling') { downloadBtn.disabled = true; @@ -2210,7 +2214,7 @@ export const resolveDownloadMethods = { const { missing, category } = info; const downloadFolderContext = this.getDownloadFolderContext(progress, info); - if (progress.status === 'downloading' || progress.status === 'starting' || progress.status === 'paused') { + if (progress.status === 'downloading' || progress.status === 'starting' || progress.status === 'paused' || progress.status === 'verifying') { this.renderDownloadSnapshot(downloadId, snapshot, { progressDiv, downloadBtn }); if (typeof this.requestQueuePanelUpdate === 'function') { this.requestQueuePanelUpdate(); @@ -2282,6 +2286,7 @@ export const resolveDownloadMethods = { this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); this.refreshLocalMatchesUiForMissing?.(missing); + this.showNotification(progress.error || 'Download failed', 'error'); } else if (progress.status === 'cancelled') { this.finalizeCancelledDownloadFrontend?.(downloadId, info, progress);