From f691af6387717fd46926166100d522e949fd7325 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 5 Jul 2026 05:16:20 +0200 Subject: [PATCH] annotate active downloads in local matches --- core/resolver.py | 113 +++++++++++++++++- web/css/resolver-main.css | 12 ++ .../actions/resolve_download_methods.js | 4 + web/resolver/search/search_panel.js | 88 +++++++++++++- 4 files changed, 212 insertions(+), 5 deletions(-) diff --git a/core/resolver.py b/core/resolver.py index 3d3d3da..0aa079c 100644 --- a/core/resolver.py +++ b/core/resolver.py @@ -53,6 +53,7 @@ from .path_utils import ( _LOCAL_HASH_MATCH_CACHE_LOCK = threading.Lock() _LOCAL_HASH_MATCH_CACHE: Optional[Dict[str, List[Dict[str, Any]]]] = None +_ACTIVE_DOWNLOAD_STATUSES = {"starting", "downloading", "paused", "cancelling"} def invalidate_local_hash_match_cache() -> None: @@ -69,6 +70,105 @@ def _clone_hash_match(match: Dict[str, Any]) -> Dict[str, Any]: return cloned +def _normalize_download_match_path(path: Any) -> str: + text = str(path or "").strip() + if not text: + return "" + + try: + return os.path.normcase(os.path.abspath(os.path.normpath(text))) + except (OSError, ValueError): + return os.path.normcase(os.path.normpath(text)) + + +def _get_active_downloads_by_path() -> Dict[str, Dict[str, Any]]: + try: + from .downloader import get_all_progress + + progress_items = get_all_progress() + except Exception: + return {} + + active: Dict[str, Dict[str, Any]] = {} + for download_id, progress in progress_items.items(): + if not isinstance(progress, dict): + continue + + status = str(progress.get("status") or "").strip().lower() + if status not in _ACTIVE_DOWNLOAD_STATUSES: + continue + + path = progress.get("path") or "" + if not path and progress.get("directory") and progress.get("filename"): + path = os.path.join(str(progress["directory"]), str(progress["filename"])) + + path_key = _normalize_download_match_path(path) + if not path_key: + continue + + active[path_key] = { + "download_id": download_id, + "download_status": status, + "download_progress": progress.get("progress", 0), + "downloaded": progress.get("downloaded", 0), + "total_size": progress.get("total_size", 0), + } + + return active + + +def annotate_local_matches_with_download_state( + matches: List[Dict[str, Any]], + active_downloads_by_path: Optional[Dict[str, Dict[str, Any]]] = None, +) -> List[Dict[str, Any]]: + active_downloads = ( + active_downloads_by_path + if active_downloads_by_path is not None + else _get_active_downloads_by_path() + ) + if not active_downloads: + return matches + + enriched_matches: List[Dict[str, Any]] = [] + for match in matches: + if not isinstance(match, dict): + enriched_matches.append(match) + continue + + model = match.get("model") if isinstance(match.get("model"), dict) else {} + candidate_paths = [ + model.get("path"), + model.get("resolved_path"), + match.get("path"), + match.get("resolved_path"), + ] + download_info = None + for candidate_path in candidate_paths: + path_key = _normalize_download_match_path(candidate_path) + if path_key and path_key in active_downloads: + download_info = active_downloads[path_key] + break + + if not download_info: + enriched_matches.append(match) + continue + + enriched_match = dict(match) + enriched_model = dict(model) + download_fields = { + **download_info, + "is_downloading": True, + "downloading": True, + } + enriched_match.update(download_fields) + if enriched_model: + enriched_model.update(download_fields) + enriched_match["model"] = enriched_model + enriched_matches.append(enriched_match) + + return enriched_matches + + def _is_local_hash_match_candidate(model: Dict[str, Any]) -> bool: model_path = str(model.get("path") or "").strip() if not model_path: @@ -389,7 +489,7 @@ def search_local_matches( deduplicated_matches[idx] = match seen_absolute_paths[dedupe_key] = match - return deduplicated_matches + return annotate_local_matches_with_download_state(deduplicated_matches) def _collect_hashes_from_container(value: Any) -> List[str]: @@ -477,9 +577,10 @@ def search_local_matches_by_hash( else 1 ) + annotated_matches = annotate_local_matches_with_download_state(matches) if max_matches > 0: - return matches[:max_matches] - return matches + return annotated_matches[:max_matches] + return annotated_matches def get_local_model_hash_metadata( @@ -867,6 +968,7 @@ def analyze_and_find_matches( ) local_match_cache: Dict[Tuple[str, str], List[Dict[str, Any]]] = {} + active_downloads_by_path = _get_active_downloads_by_path() def get_match_target(model_ref: Dict[str, Any]) -> Optional[str]: target_for_matching = ( @@ -961,7 +1063,10 @@ def analyze_and_find_matches( threshold=similarity_threshold, max_results=max_matches_per_model, ) - deduplicated_matches = deduplicate_matches(matches) + deduplicated_matches = annotate_local_matches_with_download_state( + deduplicate_matches(matches), + active_downloads_by_path, + ) local_match_cache[cache_key] = deduplicated_matches return deduplicated_matches diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 8736a00..9b77014 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -1923,11 +1923,23 @@ cursor: help; } +.mr-match-status-downloading { + color: #67e8f9; + background: rgba(6, 182, 212, 0.12); + box-shadow: inset 0 0 0 1px rgba(6, 182, 212, 0.24); + cursor: help; +} + .mr-match-status-bad-folder:focus-visible { outline: 2px solid rgba(248, 113, 113, 0.58); outline-offset: 2px; } +.mr-match-status-downloading:focus-visible { + outline: 2px solid rgba(6, 182, 212, 0.58); + outline-offset: 2px; +} + .mr-local-alternatives-toggle { display: flex; align-items: center; diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index d891de0..6daddf0 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -1775,6 +1775,7 @@ export const resolveDownloadMethods = { if (progress.status === 'downloading' || progress.status === 'starting' || progress.status === 'paused') { this.renderDownloadSnapshot(downloadId, snapshot, { progressDiv, downloadBtn }); + this.refreshLocalMatchesUiForMissing?.(missing); if (typeof this.requestQueuePanelUpdate === 'function') { this.requestQueuePanelUpdate(); } else { @@ -1800,6 +1801,7 @@ export const resolveDownloadMethods = { delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); + this.refreshLocalMatchesUiForMissing?.(missing); this.showNotification(alreadyExists ? `Already downloaded: ${progress.filename}` : `Downloaded: ${progress.filename}`, 'success', { @@ -1831,6 +1833,7 @@ export const resolveDownloadMethods = { delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); + this.refreshLocalMatchesUiForMissing?.(missing); } else if (progress.status === 'cancelled') { const cancelledSnapshot = this.rememberDownloadUiState(downloadId, info, progress, { @@ -1842,6 +1845,7 @@ export const resolveDownloadMethods = { delete this.activeDownloads[downloadId]; this.updateDownloadAllButtonState(); this.updateQueuePanel?.(); + this.refreshLocalMatchesUiForMissing?.(missing); this.showNotification('Download cancelled', 'info'); } else { diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index 6162ac0..8d16cbd 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -2407,8 +2407,94 @@ export const searchPanelMethods = { return `Bad folder`; }, + getActiveDownloadInfoForLocalMatch(match = {}) { + const model = match.model || {}; + const normalizePath = (value = '') => this.normalizeLocalMatchPathIdentity?.(value) || String(value || '').trim().replace(/\\/g, '/').replace(/\/+/g, '/').toLowerCase(); + const joinPath = (...parts) => normalizePath(parts.filter(Boolean).join('/')); + const activeStatuses = new Set(['starting', 'downloading', 'paused', 'cancelling']); + const matchAbsolutePaths = [ + model.path, + model.resolved_path, + match.path, + match.resolved_path + ].map(normalizePath).filter(Boolean); + const matchRelativePaths = [ + model.relative_path, + match.relative_path + ].map(normalizePath).filter(Boolean); + const matchFilename = normalizePath(model.filename || match.filename || ''); + + for (const [downloadId, info] of Object.entries(this.activeDownloads || {})) { + const progress = info?.lastProgress || info?.statusSnapshot?.progress || {}; + const status = String(progress.status || info?.lastStatus || info?.statusSnapshot?.status || 'starting').trim().toLowerCase(); + if (!activeStatuses.has(status)) continue; + + const filename = progress.filename || info?.filename || ''; + const absoluteCandidates = [ + progress.path, + info?.downloadPath, + info?.statusSnapshot?.downloadPath, + joinPath(progress.directory || '', filename), + joinPath(info?.downloadDirectory || '', filename), + joinPath(info?.statusSnapshot?.downloadDirectory || '', filename) + ].map(normalizePath).filter(Boolean); + const relativeCandidates = [ + info?.subfolder && filename ? joinPath(info.subfolder, filename) : '', + progress.relative_path, + info?.relativePath + ].map(normalizePath).filter(Boolean); + + const absoluteMatch = absoluteCandidates.some(activePath => ( + matchAbsolutePaths.includes(activePath) + || matchRelativePaths.some(relativePath => activePath.endsWith(`/${relativePath}`)) + )); + const relativeMatch = relativeCandidates.some(activeRelative => ( + matchRelativePaths.includes(activeRelative) + || matchAbsolutePaths.some(matchPath => matchPath.endsWith(`/${activeRelative}`)) + )); + if (!absoluteMatch && !relativeMatch) continue; + + return { + download_id: downloadId, + download_status: status, + download_progress: progress.progress, + downloaded: progress.downloaded, + total_size: progress.total_size, + filename: filename || matchFilename, + is_downloading: true, + downloading: true + }; + } + + return null; + }, + + renderLocalMatchDownloadingBadge(match = {}) { + const model = match.model || {}; + const activeDownloadInfo = this.getActiveDownloadInfoForLocalMatch?.(match) || null; + const isDownloading = Boolean( + match.is_downloading + || match.downloading + || model.is_downloading + || model.downloading + || activeDownloadInfo + ); + if (!isDownloading) return ''; + + const status = String(activeDownloadInfo?.download_status || match.download_status || model.download_status || '').trim().toLowerCase(); + const progress = Number(activeDownloadInfo?.download_progress ?? match.download_progress ?? model.download_progress); + const progressLabel = Number.isFinite(progress) && progress > 0 && progress < 100 + ? ` (${Math.round(progress)}%)` + : ''; + const statusLabel = status === 'paused' + ? 'paused' + : (status === 'starting' ? 'starting' : 'still downloading'); + const tooltip = `The download for this local file is ${statusLabel}${progressLabel}. It may be incomplete until the download finishes.`; + return `Downloading`; + }, + renderLocalMatchStatusGroup(missing = {}, match = {}, hashLabelMap = null) { - return `${this.renderLocalMatchStatus(match, hashLabelMap)}${this.renderLocalMatchBadFolderBadge(missing, match)}`; + return `${this.renderLocalMatchStatus(match, hashLabelMap)}${this.renderLocalMatchDownloadingBadge(match)}${this.renderLocalMatchBadFolderBadge(missing, match)}`; }, areLocalMatchAlternativesCollapsed() {