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