feat: add search cancellation API and harden CivArchive source resolution
Add a /model_resolver/search-cancel/{progress_id} endpoint with a
SearchCancelled exception so in-flight source searches abort cleanly on
batch cancel. Track sourceProgressIds alongside sourceControllers so the
frontend can cancel backend progress polls. Rework CivArchive extraction
with robust __NEXT_DATA__ parsing, hash-page file resolution, model-link
scraping, confidence-based candidate filtering, and expanded type maps.
Add conftest.py for pytest path setup, update .gitignore for scratch/,
and refresh huggingface-author-index.json.
This commit is contained in:
+2
-1
@@ -28,7 +28,8 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
scratch/
|
||||
|
||||
# User settings – contains API keys and tokens, must NOT be committed
|
||||
model_resolver_settings.json
|
||||
tests/
|
||||
tests/
|
||||
|
||||
+129
@@ -9,6 +9,22 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
|
||||
if not __package__ or __package__ == "":
|
||||
this_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.dirname(this_dir)
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
package_name = os.path.basename(this_dir)
|
||||
__package__ = package_name
|
||||
|
||||
current_module = sys.modules.get(__name__)
|
||||
if current_module:
|
||||
sys.modules[package_name] = current_module
|
||||
if not hasattr(current_module, "__path__"):
|
||||
current_module.__path__ = [this_dir]
|
||||
from .core.log_system.log_funcs import (
|
||||
create_module_logger,
|
||||
log_debug,
|
||||
@@ -38,6 +54,7 @@ class ModelResolverExtension:
|
||||
self.logger = create_module_logger(__name__)
|
||||
self.analysis_progress = {}
|
||||
self.search_progress = {}
|
||||
self.cancelled_search_progress = set()
|
||||
self.search_progress_lock = threading.Lock()
|
||||
self.search_result_timestamps = {}
|
||||
|
||||
@@ -959,6 +976,35 @@ class ModelResolverExtension:
|
||||
]
|
||||
for progress_id in expired:
|
||||
self.search_progress.pop(progress_id, None)
|
||||
self.cancelled_search_progress.difference_update(expired)
|
||||
|
||||
def is_search_progress_cancelled(progress_id):
|
||||
if not progress_id:
|
||||
return False
|
||||
with self.search_progress_lock:
|
||||
return progress_id in self.cancelled_search_progress
|
||||
|
||||
def mark_search_progress_cancelled(progress_id, source=""):
|
||||
if not progress_id:
|
||||
return False
|
||||
cleanup_search_progress()
|
||||
now = time.time()
|
||||
with self.search_progress_lock:
|
||||
current = self.search_progress.get(progress_id, {})
|
||||
self.cancelled_search_progress.add(progress_id)
|
||||
self.search_progress[progress_id] = {
|
||||
**current,
|
||||
"progress_id": progress_id,
|
||||
"source": source or current.get("source", ""),
|
||||
"stage": "cancelled",
|
||||
"message": "Cancelled",
|
||||
"status": "cancelled",
|
||||
"percent": 100,
|
||||
"cancelled": True,
|
||||
"updated_at": now,
|
||||
"created_at": current.get("created_at", now),
|
||||
}
|
||||
return True
|
||||
|
||||
def update_search_progress(
|
||||
progress_id,
|
||||
@@ -975,6 +1021,23 @@ class ModelResolverExtension:
|
||||
now = time.time()
|
||||
with self.search_progress_lock:
|
||||
current = self.search_progress.get(progress_id, {})
|
||||
if (
|
||||
progress_id in self.cancelled_search_progress
|
||||
and status != "cancelled"
|
||||
):
|
||||
self.search_progress[progress_id] = {
|
||||
**current,
|
||||
"progress_id": progress_id,
|
||||
"source": source or current.get("source", ""),
|
||||
"stage": "cancelled",
|
||||
"message": "Cancelled",
|
||||
"status": "cancelled",
|
||||
"percent": 100,
|
||||
"cancelled": True,
|
||||
"updated_at": now,
|
||||
"created_at": current.get("created_at", now),
|
||||
}
|
||||
return
|
||||
payload = {
|
||||
**current,
|
||||
"progress_id": progress_id,
|
||||
@@ -1005,10 +1068,33 @@ class ModelResolverExtension:
|
||||
return web.json_response({"exists": False})
|
||||
return web.json_response({"exists": True, **progress})
|
||||
|
||||
@routes.post("/model_resolver/search-cancel/{progress_id}")
|
||||
@json_api_endpoint("search-cancel", return_success_on_error=True)
|
||||
async def cancel_search_progress_route(request):
|
||||
"""Mark an in-flight source search as cancelled."""
|
||||
progress_id = request.match_info.get("progress_id", "").strip()
|
||||
if not progress_id:
|
||||
return web.json_response(
|
||||
{"error": "Progress ID is required"}, status=400
|
||||
)
|
||||
marked = mark_search_progress_cancelled(progress_id)
|
||||
return web.json_response(
|
||||
{"success": True, "cancelled": marked, "progress_id": progress_id}
|
||||
)
|
||||
|
||||
@routes.post("/model_resolver/search")
|
||||
async def search_sources(request):
|
||||
"""Search for model download sources."""
|
||||
try:
|
||||
class SearchCancelled(BaseException):
|
||||
# Progress helpers swallow ordinary callback errors; cancellation
|
||||
# must bubble out of source loops and stop follow-up requests.
|
||||
pass
|
||||
|
||||
def raise_if_search_cancelled(source=""):
|
||||
if is_search_progress_cancelled(progress_id):
|
||||
raise SearchCancelled("Search cancelled")
|
||||
|
||||
def format_log_value(value):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
@@ -1194,6 +1280,7 @@ class ModelResolverExtension:
|
||||
"Preparing search",
|
||||
8,
|
||||
)
|
||||
raise_if_search_cancelled(progress_source)
|
||||
|
||||
if force_search:
|
||||
update_search_progress(
|
||||
@@ -1218,6 +1305,7 @@ class ModelResolverExtension:
|
||||
"Force search enabled: cleared cache "
|
||||
+ format_log_fields(sources=sorted(normalized_sources))
|
||||
)
|
||||
raise_if_search_cancelled(progress_source)
|
||||
|
||||
log_info(
|
||||
f"Search [{','.join(sorted(normalized_sources))}] request "
|
||||
@@ -1405,6 +1493,7 @@ class ModelResolverExtension:
|
||||
seen_hash_sources = set()
|
||||
for source_key in ("huggingface", "civitai", "civarchive"):
|
||||
for source_result in iter_result_items(payload.get(source_key)):
|
||||
raise_if_search_cancelled(source_key)
|
||||
if not is_hash_lookup_candidate(source_result):
|
||||
continue
|
||||
sha256 = get_result_sha256(source_result)
|
||||
@@ -1469,6 +1558,7 @@ class ModelResolverExtension:
|
||||
percent_max=None,
|
||||
):
|
||||
def source_progress_callback(payload):
|
||||
raise_if_search_cancelled(source_key)
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
|
||||
@@ -1519,6 +1609,7 @@ class ModelResolverExtension:
|
||||
):
|
||||
if initial_message is None:
|
||||
initial_message = f"Querying {source_key.capitalize()}"
|
||||
raise_if_search_cancelled(source_key)
|
||||
update_search_progress(
|
||||
progress_id,
|
||||
source_key,
|
||||
@@ -1532,7 +1623,9 @@ class ModelResolverExtension:
|
||||
+ format_log_fields(**start_fields)
|
||||
)
|
||||
try:
|
||||
raise_if_search_cancelled(source_key)
|
||||
source_results, source_found = search_task_fn()
|
||||
raise_if_search_cancelled(source_key)
|
||||
done_messages = {
|
||||
"local": "Local database checked",
|
||||
"huggingface": "HuggingFace checked",
|
||||
@@ -1561,13 +1654,16 @@ class ModelResolverExtension:
|
||||
search_fn,
|
||||
any_model_label,
|
||||
):
|
||||
raise_if_search_cancelled(source_key)
|
||||
res = search_fn(
|
||||
base_model_context or None,
|
||||
make_source_progress_callback(source_key),
|
||||
)
|
||||
raise_if_search_cancelled(source_key)
|
||||
log_search_result(source_key, res)
|
||||
|
||||
if not res and base_model_context:
|
||||
raise_if_search_cancelled(source_key)
|
||||
update_search_progress(
|
||||
progress_id,
|
||||
source_key,
|
||||
@@ -1587,6 +1683,7 @@ class ModelResolverExtension:
|
||||
None,
|
||||
make_source_progress_callback(source_key, 72, 92),
|
||||
)
|
||||
raise_if_search_cancelled(source_key)
|
||||
log_search_result(f"{source_key}/any_model", res)
|
||||
if res:
|
||||
res = mark_any_model_fallback(res)
|
||||
@@ -1993,10 +2090,12 @@ class ModelResolverExtension:
|
||||
"Waiting for search sources",
|
||||
18,
|
||||
)
|
||||
raise_if_search_cancelled(progress_source)
|
||||
|
||||
for source_results, source_found in await asyncio.gather(
|
||||
*search_tasks
|
||||
):
|
||||
raise_if_search_cancelled(progress_source)
|
||||
for source_key, source_result in source_results.items():
|
||||
if source_key == "source_errors":
|
||||
results["source_errors"].update(source_result or {})
|
||||
@@ -2006,7 +2105,9 @@ class ModelResolverExtension:
|
||||
if source_found:
|
||||
results["found"] = True
|
||||
|
||||
raise_if_search_cancelled(progress_source)
|
||||
results["local_hash_matches"] = collect_local_hash_matches(results)
|
||||
raise_if_search_cancelled(progress_source)
|
||||
|
||||
log_info(
|
||||
f"Search [{','.join(results['searched_sources'])}] done "
|
||||
@@ -2025,6 +2126,34 @@ class ModelResolverExtension:
|
||||
stamp_search_results(results)
|
||||
return web.json_response(results)
|
||||
|
||||
except SearchCancelled:
|
||||
update_search_progress(
|
||||
progress_id if "progress_id" in locals() else "",
|
||||
progress_source if "progress_source" in locals() else "",
|
||||
"cancelled",
|
||||
"Cancelled",
|
||||
100,
|
||||
status="cancelled",
|
||||
cancelled=True,
|
||||
)
|
||||
log_info(
|
||||
"Search cancelled "
|
||||
+ format_log_fields(
|
||||
source=progress_source if "progress_source" in locals() else "",
|
||||
progress_id=progress_id if "progress_id" in locals() else "",
|
||||
)
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"cancelled": True,
|
||||
"found": False,
|
||||
"searched_sources": sorted(normalized_sources)
|
||||
if "normalized_sources" in locals()
|
||||
else [],
|
||||
"source_errors": {},
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
update_search_progress(
|
||||
progress_id if "progress_id" in locals() else "",
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the parent directory of this custom node to sys.path so it can be imported as a package
|
||||
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if parent_dir not in sys.path:
|
||||
sys.path.insert(0, parent_dir)
|
||||
|
||||
# Tell pytest to ignore __init__.py during test collection
|
||||
collect_ignore = ["__init__.py"]
|
||||
+695
-51
@@ -38,6 +38,8 @@ from ..log_system.log_funcs import (
|
||||
log_exception,
|
||||
)
|
||||
|
||||
_coerce_int = to_int
|
||||
|
||||
CIVARCHIVE_BASE_URL = "https://civarchive.com"
|
||||
CIVARCHIVE_API_URL = f"{CIVARCHIVE_BASE_URL}/api"
|
||||
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
@@ -47,6 +49,8 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
|
||||
DEFAULT_CIVARCHIVE_CANDIDATE_LIMIT = 10
|
||||
MAX_CIVARCHIVE_CANDIDATE_LIMIT = 30
|
||||
SEARCH_RESULT_DETAIL_LIMIT = 5
|
||||
HASH_PAGE_MODEL_LINK_LIMIT = 5
|
||||
MODEL_TITLE_MATCH_THRESHOLD = 82.0
|
||||
|
||||
|
||||
@@ -136,7 +140,7 @@ def _normalize_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def _extract_next_data(html_text: str) -> Dict[str, Any]:
|
||||
match = re.search(
|
||||
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||
r'<script\b(?=[^>]*\bid=["\']__NEXT_DATA__["\'])(?=[^>]*\btype=["\']application/json["\'])[^>]*>(.*?)</script>',
|
||||
html_text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
@@ -194,30 +198,144 @@ def _request_json(
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_embedded_html_text(html_text: str) -> str:
|
||||
if not html_text:
|
||||
return ""
|
||||
return (
|
||||
html.unescape(html_text)
|
||||
.replace("\\/", "/")
|
||||
.replace("\\u002F", "/")
|
||||
.replace("\\u0026", "&")
|
||||
)
|
||||
|
||||
|
||||
def _request_page_text(
|
||||
path_or_url: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = 20,
|
||||
) -> Optional[str]:
|
||||
url = urljoin(CIVARCHIVE_BASE_URL, path_or_url)
|
||||
request_params = {k: v for k, v in (params or {}).items() if v is not None}
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
params=request_params,
|
||||
headers=REQUEST_HEADERS,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
log_warn(f"CivArchive page request failed: url={url}, error={e}")
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
log_debug(f"CivArchive page returned {response.status_code}: url={url}")
|
||||
return None
|
||||
|
||||
return response.text
|
||||
|
||||
|
||||
def _extract_model_links_from_html(html_text: str) -> List[Dict[str, Optional[int]]]:
|
||||
text = _normalize_embedded_html_text(html_text)
|
||||
if not text:
|
||||
return []
|
||||
|
||||
links: List[Dict[str, Optional[int]]] = []
|
||||
seen = set()
|
||||
|
||||
def add_link(model_id: Any, version_id: Any = None) -> None:
|
||||
resolved_model_id = _coerce_int(model_id)
|
||||
resolved_version_id = _coerce_int(version_id) if version_id else None
|
||||
key = (resolved_model_id, resolved_version_id)
|
||||
if not resolved_model_id or key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
links.append(
|
||||
{
|
||||
"model_id": resolved_model_id,
|
||||
"version_id": resolved_version_id,
|
||||
}
|
||||
)
|
||||
|
||||
pattern = re.compile(r"(?<!/download)/models/(\d+)(?:\?[^\"'<>\s]*?modelVersionId=(\d+))?")
|
||||
|
||||
def add_links_from_text(value: Any) -> None:
|
||||
if not isinstance(value, str) or "/models/" not in value:
|
||||
return
|
||||
for match in pattern.finditer(_normalize_embedded_html_text(value)):
|
||||
add_link(match.group(1), match.group(2))
|
||||
|
||||
def walk_next_data(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
model_id = (
|
||||
value.get("model_id")
|
||||
or value.get("modelId")
|
||||
or value.get("civitai_model_id")
|
||||
)
|
||||
version_id = (
|
||||
value.get("model_version_id")
|
||||
or value.get("modelVersionId")
|
||||
or value.get("version_id")
|
||||
or value.get("civitai_model_version_id")
|
||||
or (value.get("id") if model_id else None)
|
||||
)
|
||||
if model_id and version_id:
|
||||
add_link(model_id, version_id)
|
||||
|
||||
for key in ("href", "url", "platform_url", "version_url"):
|
||||
add_links_from_text(value.get(key))
|
||||
|
||||
for nested in value.values():
|
||||
walk_next_data(nested)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
walk_next_data(item)
|
||||
|
||||
walk_next_data(_extract_next_data(html_text))
|
||||
|
||||
for match in pattern.finditer(text):
|
||||
add_link(match.group(1), match.group(2))
|
||||
|
||||
filtered_links = []
|
||||
for link in links:
|
||||
if not link.get("version_id"):
|
||||
continue
|
||||
filtered_links.append(link)
|
||||
filtered_links.extend(link for link in links if not link.get("version_id"))
|
||||
return filtered_links
|
||||
|
||||
|
||||
def _extract_download_urls_from_html(html_text: str) -> List[str]:
|
||||
text = _normalize_embedded_html_text(html_text)
|
||||
if not text:
|
||||
return []
|
||||
|
||||
urls: List[str] = []
|
||||
pattern = re.compile(
|
||||
r"https?://(?:huggingface\.co|civitai\.com|civitai\.red)/[^\"'<>\s)]+",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
for match in pattern.finditer(text):
|
||||
url = _normalize_download_url(match.group(0).rstrip(".,;]})"))
|
||||
if url and url not in urls:
|
||||
urls.append(url)
|
||||
return urls
|
||||
|
||||
|
||||
def _extract_model_payload_from_page(
|
||||
model_id: int,
|
||||
version_id: Optional[int] = None,
|
||||
timeout: int = 20,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
params = {"modelVersionId": version_id} if version_id is not None else None
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
|
||||
params=params,
|
||||
headers=REQUEST_HEADERS,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
log_warn(f"CivArchive model page request failed: model_id={model_id}, error={e}")
|
||||
page_text = _request_page_text(
|
||||
f"/models/{model_id}",
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not page_text:
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
log_debug(
|
||||
f"CivArchive model page returned {response.status_code}: model_id={model_id}, version_id={version_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
next_data = _extract_next_data(response.text)
|
||||
next_data = _extract_next_data(page_text)
|
||||
page_props = next_data.get("props", {}).get("pageProps", {})
|
||||
if isinstance(page_props.get("data"), dict):
|
||||
return page_props["data"]
|
||||
@@ -230,11 +348,19 @@ def _request_model_payload(
|
||||
model_id: int,
|
||||
version_id: Optional[int] = None,
|
||||
timeout: int = 20,
|
||||
prefer_page: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
params = {"modelVersionId": version_id} if version_id is not None else None
|
||||
if prefer_page:
|
||||
payload = _extract_model_payload_from_page(model_id, version_id, timeout=timeout)
|
||||
if payload:
|
||||
return payload
|
||||
|
||||
payload = _request_json(f"/models/{model_id}", params=params, timeout=timeout)
|
||||
if payload:
|
||||
return payload
|
||||
if prefer_page:
|
||||
return None
|
||||
return _extract_model_payload_from_page(model_id, version_id, timeout=timeout)
|
||||
|
||||
|
||||
@@ -246,11 +372,12 @@ def _search_page(
|
||||
params: Dict[str, Any] = {
|
||||
"q": query,
|
||||
"rating": "all",
|
||||
"platform": "all",
|
||||
"platform": "civitai,huggingface,malcolmrey,modelscope,modelscope_cn",
|
||||
"sort": "relevance",
|
||||
}
|
||||
civarchive_type = CIVARCHIVE_API_TYPE_MAP.get(str(model_type or "").lower())
|
||||
if civarchive_type:
|
||||
params["type"] = civarchive_type
|
||||
# We do not send the 'type' parameter to CivArchive because crowdsourced model uploads
|
||||
# (especially VAEs) are frequently misclassified (e.g. as Checkpoint or null type) in their database.
|
||||
# We rely on name confidence sorting instead.
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
@@ -414,7 +541,11 @@ def _transform_file_entry(file_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
mirror["url"] = mirror_url
|
||||
transformed_mirrors.append(mirror)
|
||||
|
||||
download_url = _normalize_download_url(file_data.get("downloadUrl"))
|
||||
download_url = _normalize_download_url(
|
||||
file_data.get("downloadUrl")
|
||||
or file_data.get("download_url")
|
||||
or file_data.get("url")
|
||||
)
|
||||
name = file_data.get("name") or file_data.get("filename")
|
||||
|
||||
if not download_url:
|
||||
@@ -726,13 +857,19 @@ def _normalize_archive_version(
|
||||
def get_civarchive_model_details(
|
||||
model_id: int,
|
||||
version_id: Optional[int] = None,
|
||||
prefer_page: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch and normalize full CivArchive model details for the resolver UI."""
|
||||
if not model_id:
|
||||
return None
|
||||
|
||||
payload = _request_model_payload(model_id, version_id, timeout=25)
|
||||
if not payload and version_id:
|
||||
payload = _request_model_payload(
|
||||
model_id,
|
||||
version_id,
|
||||
timeout=25,
|
||||
prefer_page=prefer_page,
|
||||
)
|
||||
if not payload and version_id and not prefer_page:
|
||||
resolved = resolve_civarchive_model_version(model_id, version_id)
|
||||
if not resolved:
|
||||
return None
|
||||
@@ -1029,12 +1166,258 @@ def _find_model_title_match_in_model_details(
|
||||
return None
|
||||
|
||||
|
||||
def _build_result_from_model_details(
|
||||
model_details: Dict[str, Any],
|
||||
query: str = "",
|
||||
exact_only: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
selected_version = model_details.get("selected_version")
|
||||
if not isinstance(selected_version, dict):
|
||||
return None
|
||||
|
||||
files = selected_version.get("files") or []
|
||||
if not isinstance(files, list):
|
||||
files = [files]
|
||||
|
||||
selected_file = None
|
||||
best_confidence = -1.0
|
||||
for file_info in files:
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
filename = file_info.get("name") or file_info.get("filename") or ""
|
||||
confidence = calculate_archived_model_confidence(
|
||||
query,
|
||||
model_details.get("name", ""),
|
||||
selected_version.get("name", ""),
|
||||
filename,
|
||||
)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
selected_file = file_info
|
||||
|
||||
if selected_file is None:
|
||||
selected_file = _select_primary_model_file(files)
|
||||
if selected_file:
|
||||
best_confidence = calculate_archived_model_confidence(
|
||||
query,
|
||||
model_details.get("name", ""),
|
||||
selected_version.get("name", ""),
|
||||
selected_file.get("name") or selected_file.get("filename") or "",
|
||||
)
|
||||
|
||||
if selected_file is None:
|
||||
return None
|
||||
if exact_only and best_confidence < 100.0:
|
||||
return None
|
||||
|
||||
result = _build_result_from_normalized_version(
|
||||
model_details=model_details,
|
||||
version=selected_version,
|
||||
file_info=selected_file,
|
||||
match_type="exact" if best_confidence == 100.0 else "similar",
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
hashes = selected_file.get("hashes") if isinstance(selected_file.get("hashes"), dict) else {}
|
||||
sha256 = (
|
||||
selected_file.get("sha256")
|
||||
or selected_file.get("hash")
|
||||
or hashes.get("SHA256")
|
||||
or hashes.get("sha256")
|
||||
)
|
||||
result["confidence"] = best_confidence
|
||||
result["sha256"] = sha256
|
||||
result["hash"] = sha256
|
||||
result["hashes"] = hashes
|
||||
return result
|
||||
|
||||
|
||||
def _extract_hash_page_files(html_text: str) -> List[Dict[str, Any]]:
|
||||
next_data = _extract_next_data(html_text)
|
||||
page_props = next_data.get("props", {}).get("pageProps", {})
|
||||
files = page_props.get("files") if isinstance(page_props, dict) else []
|
||||
if not isinstance(files, list):
|
||||
files = [files]
|
||||
return [file_info for file_info in files if isinstance(file_info, dict)]
|
||||
|
||||
|
||||
def _hash_page_file_matches_model(
|
||||
file_info: Dict[str, Any],
|
||||
model_id: Optional[int],
|
||||
version_id: Optional[int],
|
||||
) -> bool:
|
||||
if not model_id and not version_id:
|
||||
return False
|
||||
|
||||
file_model_id = _coerce_int(file_info.get("model_id") or file_info.get("modelId"))
|
||||
file_version_id = _coerce_int(
|
||||
file_info.get("model_version_id")
|
||||
or file_info.get("modelVersionId")
|
||||
or file_info.get("version_id")
|
||||
)
|
||||
if model_id and file_model_id and file_model_id != _coerce_int(model_id):
|
||||
return False
|
||||
if version_id and file_version_id and file_version_id != _coerce_int(version_id):
|
||||
return False
|
||||
return bool((model_id and file_model_id) or (version_id and file_version_id))
|
||||
|
||||
|
||||
def _select_hash_page_file(
|
||||
files: List[Dict[str, Any]],
|
||||
query: str,
|
||||
model_id: Optional[int] = None,
|
||||
version_id: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
best_file = None
|
||||
best_score = -1.0
|
||||
for file_info in files:
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
if not _normalize_download_url(file_info.get("url") or file_info.get("downloadUrl")):
|
||||
continue
|
||||
|
||||
filename = file_info.get("filename") or file_info.get("name") or ""
|
||||
confidence = calculate_archived_model_confidence(query, "", "", filename)
|
||||
model_match = _hash_page_file_matches_model(file_info, model_id, version_id)
|
||||
score = confidence + (8.0 if model_match else 0.0)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_file = file_info
|
||||
|
||||
if best_file is None:
|
||||
return None
|
||||
|
||||
filename = best_file.get("filename") or best_file.get("name") or ""
|
||||
confidence = calculate_archived_model_confidence(query, "", "", filename)
|
||||
if confidence >= 70.0 or _hash_page_file_matches_model(best_file, model_id, version_id):
|
||||
return best_file
|
||||
return None
|
||||
|
||||
|
||||
def _select_model_details_file(
|
||||
files: List[Dict[str, Any]],
|
||||
query: str,
|
||||
sha256: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
normalized_hash = (sha256 or "").lower()
|
||||
best_file = None
|
||||
best_confidence = -1.0
|
||||
|
||||
for file_info in files:
|
||||
if not isinstance(file_info, dict):
|
||||
continue
|
||||
if not _collect_normalized_download_urls(file_info):
|
||||
continue
|
||||
|
||||
hashes = file_info.get("hashes") if isinstance(file_info.get("hashes"), dict) else {}
|
||||
file_hash = (
|
||||
file_info.get("sha256")
|
||||
or file_info.get("hash")
|
||||
or hashes.get("SHA256")
|
||||
or hashes.get("sha256")
|
||||
)
|
||||
if normalized_hash and str(file_hash or "").lower() == normalized_hash:
|
||||
return file_info
|
||||
|
||||
filename = file_info.get("name") or file_info.get("filename") or ""
|
||||
confidence = calculate_archived_model_confidence(query, "", "", filename)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_file = file_info
|
||||
|
||||
if best_file is None:
|
||||
return None
|
||||
|
||||
filename = best_file.get("name") or best_file.get("filename") or ""
|
||||
confidence = calculate_archived_model_confidence(query, "", "", filename)
|
||||
return best_file if confidence >= 70.0 else None
|
||||
|
||||
|
||||
def _prefer_query_matching_mirror(
|
||||
file_info: Dict[str, Any],
|
||||
query: str,
|
||||
) -> Dict[str, Any]:
|
||||
mirrors = file_info.get("mirrors") or []
|
||||
if not isinstance(mirrors, list):
|
||||
mirrors = [mirrors]
|
||||
|
||||
best_mirror = None
|
||||
best_confidence = -1.0
|
||||
for mirror in mirrors:
|
||||
if not isinstance(mirror, dict):
|
||||
continue
|
||||
url = _normalize_download_url(mirror.get("url"))
|
||||
filename = mirror.get("filename") or mirror.get("name") or ""
|
||||
if not url or not filename:
|
||||
continue
|
||||
confidence = calculate_archived_model_confidence(query, "", "", filename)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
best_mirror = mirror
|
||||
|
||||
if not best_mirror or best_confidence < 70.0:
|
||||
return file_info
|
||||
|
||||
preferred_url = _normalize_download_url(best_mirror.get("url"))
|
||||
download_urls = []
|
||||
if preferred_url:
|
||||
download_urls.append(preferred_url)
|
||||
for url in _collect_normalized_download_urls(file_info):
|
||||
if url and url not in download_urls:
|
||||
download_urls.append(url)
|
||||
|
||||
preferred = dict(file_info)
|
||||
preferred["name"] = best_mirror.get("filename") or best_mirror.get("name")
|
||||
preferred["filename"] = preferred["name"]
|
||||
preferred["download_url"] = preferred_url or preferred.get("download_url")
|
||||
preferred["downloadUrl"] = preferred["download_url"]
|
||||
preferred["download_urls"] = download_urls
|
||||
return preferred
|
||||
|
||||
|
||||
def _build_result_from_hash_page_file(
|
||||
model_details: Dict[str, Any],
|
||||
version: Dict[str, Any],
|
||||
file_info: Dict[str, Any],
|
||||
query: str,
|
||||
sha256: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
model_id = _coerce_int(model_details.get("model_id") or file_info.get("model_id"))
|
||||
version_id = _coerce_int(version.get("id") or file_info.get("model_version_id"))
|
||||
normalized_file = _normalize_archive_file(file_info, model_id, version_id)
|
||||
confidence = calculate_archived_model_confidence(
|
||||
query,
|
||||
model_details.get("name", ""),
|
||||
version.get("name", ""),
|
||||
normalized_file.get("name") or normalized_file.get("filename") or "",
|
||||
)
|
||||
result = _build_result_from_normalized_version(
|
||||
model_details=model_details,
|
||||
version=version,
|
||||
file_info=normalized_file,
|
||||
match_type="exact" if confidence == 100.0 else "similar",
|
||||
)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
result["confidence"] = confidence
|
||||
if sha256:
|
||||
result["sha256"] = sha256
|
||||
result["hash"] = sha256
|
||||
hashes = result.get("hashes") if isinstance(result.get("hashes"), dict) else {}
|
||||
if not hashes.get("SHA256") and not hashes.get("sha256"):
|
||||
result["hashes"] = {**hashes, "SHA256": sha256}
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_civarchive_model_title_match(
|
||||
model_id: int,
|
||||
title_query: str,
|
||||
base_model_context: Optional[str] = None,
|
||||
prefer_page: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
details = get_civarchive_model_details(model_id)
|
||||
details = get_civarchive_model_details(model_id, prefer_page=prefer_page)
|
||||
if not details:
|
||||
return None
|
||||
|
||||
@@ -1191,21 +1574,37 @@ def resolve_civarchive_model_version(
|
||||
version_id: Optional[int] = None,
|
||||
query: str = "",
|
||||
exact_only: bool = False,
|
||||
prefer_page: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve a model/version through CivArchive."""
|
||||
if model_id is None:
|
||||
return None
|
||||
|
||||
params = {"modelVersionId": version_id} if version_id is not None else None
|
||||
payload = _request_model_payload(model_id, version_id)
|
||||
payload = _request_model_payload(model_id, version_id, prefer_page=prefer_page)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
return _build_result_from_payload(
|
||||
result = _build_result_from_payload(
|
||||
payload,
|
||||
query=query or str(model_id),
|
||||
exact_only=exact_only,
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
|
||||
details = get_civarchive_model_details(
|
||||
model_id,
|
||||
version_id,
|
||||
prefer_page=prefer_page,
|
||||
)
|
||||
if not details:
|
||||
return None
|
||||
|
||||
return _build_result_from_model_details(
|
||||
details,
|
||||
query=query or str(model_id),
|
||||
exact_only=exact_only,
|
||||
)
|
||||
|
||||
|
||||
def _candidate_identity(candidate: Dict[str, Any]) -> Tuple[Any, Any, Any]:
|
||||
@@ -1219,6 +1618,212 @@ def _candidate_identity(candidate: Dict[str, Any]) -> Tuple[Any, Any, Any]:
|
||||
return (candidate.get("id"), candidate.get("url"), None)
|
||||
|
||||
|
||||
def _candidate_display_name(candidate: Dict[str, Any]) -> str:
|
||||
for key in ("name", "title", "filename", "fileName", "modelName"):
|
||||
value = candidate.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _build_result_from_search_candidate(
|
||||
candidate: Dict[str, Any],
|
||||
query: str,
|
||||
page_text: str = "",
|
||||
parsed: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
candidate_name = _candidate_display_name(candidate)
|
||||
query_basename = os.path.basename(query or "").strip()
|
||||
filename = (
|
||||
candidate.get("filename")
|
||||
or candidate.get("fileName")
|
||||
or (candidate_name if _has_known_model_extension(candidate_name) else query_basename)
|
||||
)
|
||||
confidence = calculate_archived_model_confidence(
|
||||
query,
|
||||
candidate_name,
|
||||
"",
|
||||
filename or "",
|
||||
)
|
||||
if confidence < 95.0:
|
||||
return None
|
||||
|
||||
download_urls = []
|
||||
for key in ("download_url", "downloadUrl", "url"):
|
||||
url = _normalize_download_url(candidate.get(key))
|
||||
if url and not url.startswith(CIVARCHIVE_BASE_URL) and url not in download_urls:
|
||||
download_urls.append(url)
|
||||
for url in _extract_download_urls_from_html(page_text):
|
||||
if url not in download_urls:
|
||||
download_urls.append(url)
|
||||
if not download_urls:
|
||||
return None
|
||||
|
||||
open_url = urljoin(CIVARCHIVE_BASE_URL, str(candidate.get("url") or ""))
|
||||
sha256 = (parsed or {}).get("sha256")
|
||||
return {
|
||||
"source": "civarchive",
|
||||
"name": candidate_name or filename or query_basename,
|
||||
"version_name": "",
|
||||
"type": candidate.get("type"),
|
||||
"filename": filename or query_basename,
|
||||
"url": open_url,
|
||||
"download_url": download_urls[0],
|
||||
"download_urls": download_urls,
|
||||
"size": _extract_file_size_bytes(candidate),
|
||||
"base_model": candidate.get("base_model") or candidate.get("baseModel"),
|
||||
"tags": candidate.get("tags") or [],
|
||||
"trained_words": [],
|
||||
"images": [],
|
||||
"creator": candidate.get("creator") or {},
|
||||
"platform": candidate.get("platform") or candidate.get("source"),
|
||||
"is_deleted": False,
|
||||
"match_type": "exact" if confidence == 100.0 else "similar",
|
||||
"confidence": confidence,
|
||||
"sha256": sha256,
|
||||
"hash": sha256,
|
||||
"hashes": {"SHA256": sha256} if sha256 else {},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_civarchive_model_link(
|
||||
model_id: int,
|
||||
version_id: Optional[int],
|
||||
query: str,
|
||||
exact_only: bool = False,
|
||||
base_model_context: Optional[str] = None,
|
||||
allow_model_title_match: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
details = get_civarchive_model_details(
|
||||
model_id,
|
||||
version_id,
|
||||
prefer_page=True,
|
||||
)
|
||||
if not details:
|
||||
return None
|
||||
|
||||
if allow_model_title_match:
|
||||
result = _find_model_title_match_in_model_details(
|
||||
model_id=model_id,
|
||||
model_details=details,
|
||||
title_query=query,
|
||||
base_model_context=base_model_context,
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return _build_result_from_model_details(
|
||||
details,
|
||||
query=query,
|
||||
exact_only=exact_only,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hash_search_candidate_from_page(
|
||||
candidate: Dict[str, Any],
|
||||
sha256: str,
|
||||
query: str,
|
||||
exact_only: bool = False,
|
||||
base_model_context: Optional[str] = None,
|
||||
allow_model_title_match: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
page_text = _request_page_text(f"/sha256/{sha256.lower()}")
|
||||
hash_page_files = _extract_hash_page_files(page_text or "")
|
||||
model_links = _extract_model_links_from_html(page_text or "")
|
||||
for link in model_links[:HASH_PAGE_MODEL_LINK_LIMIT]:
|
||||
model_id = link["model_id"]
|
||||
version_id = link.get("version_id")
|
||||
details = get_civarchive_model_details(
|
||||
model_id,
|
||||
version_id,
|
||||
prefer_page=True,
|
||||
)
|
||||
if not details:
|
||||
continue
|
||||
|
||||
result = None
|
||||
if allow_model_title_match:
|
||||
result = _find_model_title_match_in_model_details(
|
||||
model_id=model_id,
|
||||
model_details=details,
|
||||
title_query=query,
|
||||
base_model_context=base_model_context,
|
||||
)
|
||||
|
||||
selected_version = details.get("selected_version")
|
||||
if not result and isinstance(selected_version, dict):
|
||||
selected_model_file = _select_model_details_file(
|
||||
selected_version.get("files") or [],
|
||||
query,
|
||||
sha256=sha256,
|
||||
)
|
||||
if selected_model_file:
|
||||
selected_model_file = _prefer_query_matching_mirror(
|
||||
selected_model_file,
|
||||
query,
|
||||
)
|
||||
confidence = calculate_archived_model_confidence(
|
||||
query,
|
||||
details.get("name", ""),
|
||||
selected_version.get("name", ""),
|
||||
selected_model_file.get("name")
|
||||
or selected_model_file.get("filename")
|
||||
or "",
|
||||
)
|
||||
result = _build_result_from_normalized_version(
|
||||
model_details=details,
|
||||
version=selected_version,
|
||||
file_info=selected_model_file,
|
||||
match_type="exact" if confidence == 100.0 else "similar",
|
||||
)
|
||||
if result:
|
||||
result["confidence"] = confidence
|
||||
|
||||
selected_file = None
|
||||
if not result:
|
||||
selected_file = _select_hash_page_file(
|
||||
hash_page_files,
|
||||
query,
|
||||
model_id=model_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
if not selected_file:
|
||||
selected_file = _select_hash_page_file(hash_page_files, query)
|
||||
if selected_file:
|
||||
result = _build_result_from_hash_page_file(
|
||||
details,
|
||||
selected_version,
|
||||
selected_file,
|
||||
query,
|
||||
sha256=sha256,
|
||||
)
|
||||
|
||||
if not result:
|
||||
result = _build_result_from_model_details(
|
||||
details,
|
||||
query=query,
|
||||
exact_only=exact_only,
|
||||
)
|
||||
|
||||
if result:
|
||||
if exact_only and float(result.get("confidence") or 0) < 100.0:
|
||||
continue
|
||||
result.setdefault("sha256", sha256)
|
||||
result.setdefault("hash", sha256)
|
||||
hashes = result.get("hashes") if isinstance(result.get("hashes"), dict) else {}
|
||||
if sha256 and not hashes.get("SHA256") and not hashes.get("sha256"):
|
||||
hashes = {**hashes, "SHA256": sha256}
|
||||
result["hashes"] = hashes
|
||||
return result
|
||||
|
||||
return _build_result_from_search_candidate(
|
||||
candidate,
|
||||
query,
|
||||
page_text=page_text or "",
|
||||
parsed={"sha256": sha256},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_search_candidate(
|
||||
candidate: Dict[str, Any],
|
||||
query: str,
|
||||
@@ -1231,26 +1836,22 @@ def _resolve_search_candidate(
|
||||
return None
|
||||
|
||||
if parsed.get("sha256"):
|
||||
result = resolve_civarchive_by_hash(
|
||||
result = _resolve_hash_search_candidate_from_page(
|
||||
candidate,
|
||||
parsed["sha256"],
|
||||
query=query,
|
||||
exact_only=exact_only,
|
||||
base_model_context=base_model_context,
|
||||
allow_model_title_match=allow_model_title_match,
|
||||
)
|
||||
elif parsed.get("model_id"):
|
||||
if allow_model_title_match:
|
||||
result = _resolve_civarchive_model_title_match(
|
||||
parsed["model_id"],
|
||||
title_query=query,
|
||||
base_model_context=base_model_context,
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
|
||||
result = resolve_civarchive_model_version(
|
||||
result = _resolve_civarchive_model_link(
|
||||
parsed["model_id"],
|
||||
parsed.get("version_id"),
|
||||
query=query,
|
||||
exact_only=exact_only,
|
||||
base_model_context=base_model_context,
|
||||
allow_model_title_match=allow_model_title_match,
|
||||
)
|
||||
else:
|
||||
result = None
|
||||
@@ -1265,6 +1866,12 @@ def _resolve_search_candidate(
|
||||
return result
|
||||
|
||||
|
||||
def _is_hash_verified_exact_match(result: Optional[Dict[str, Any]], confidence: float) -> bool:
|
||||
if not result or confidence < 100.0:
|
||||
return False
|
||||
return bool(result.get("sha256") or result.get("hash"))
|
||||
|
||||
|
||||
def _build_search_queries(filename: str) -> List[str]:
|
||||
basename = os.path.basename(filename or "").strip()
|
||||
stem = os.path.splitext(basename)[0].strip()
|
||||
@@ -1295,6 +1902,7 @@ def search_civarchive(
|
||||
return []
|
||||
|
||||
limit = max(1, min(int(limit), MAX_CIVARCHIVE_CANDIDATE_LIMIT))
|
||||
detail_limit = min(limit, SEARCH_RESULT_DETAIL_LIMIT)
|
||||
cache_key = f"search::{normalized_query.lower()}::{model_type or ''}::{limit}"
|
||||
if cache_key in _search_cache:
|
||||
return list(_search_cache[cache_key])
|
||||
@@ -1304,6 +1912,8 @@ def search_civarchive(
|
||||
candidates = _search_page(normalized_query, model_type=model_type)
|
||||
|
||||
for candidate in candidates:
|
||||
if len(seen) >= detail_limit:
|
||||
break
|
||||
identity = _candidate_identity(candidate)
|
||||
if identity in seen:
|
||||
continue
|
||||
@@ -1316,7 +1926,7 @@ def search_civarchive(
|
||||
if resolved.get("confidence", 0) < 40:
|
||||
continue
|
||||
results.append(resolved)
|
||||
if len(results) >= limit:
|
||||
if len(results) >= detail_limit:
|
||||
break
|
||||
|
||||
results.sort(
|
||||
@@ -1346,6 +1956,7 @@ def search_civarchive_for_file(
|
||||
return None
|
||||
|
||||
limit = max(1, min(int(limit), MAX_CIVARCHIVE_CANDIDATE_LIMIT))
|
||||
detail_limit = min(limit, SEARCH_RESULT_DETAIL_LIMIT)
|
||||
base_model_key = _normalize_base_model(base_model_context or "")
|
||||
cache_key = (
|
||||
f"file::{normalized_filename.lower()}::{model_type or ''}::{base_model_key}::{exact_only}::{limit}"
|
||||
@@ -1414,21 +2025,45 @@ def search_civarchive_for_file(
|
||||
candidate_count=len(candidates),
|
||||
checked_candidate_count=len(seen),
|
||||
)
|
||||
candidate_count = len(candidates)
|
||||
for candidate_index, candidate in enumerate(candidates, start=1):
|
||||
# Pre-calculate preliminary confidence, filter, and sort candidates
|
||||
scored_candidates = []
|
||||
for candidate in candidates:
|
||||
candidate_name = candidate.get("name") or ""
|
||||
if not candidate_name:
|
||||
continue
|
||||
prelim = calculate_archived_model_confidence(
|
||||
normalized_filename,
|
||||
candidate_name,
|
||||
)
|
||||
if prelim >= 35.0:
|
||||
scored_candidates.append((prelim, candidate))
|
||||
|
||||
# Sort by preliminary confidence descending
|
||||
scored_candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
candidate_count = len(scored_candidates)
|
||||
for candidate_index, (prelim_confidence, candidate) in enumerate(scored_candidates, start=1):
|
||||
if len(seen) >= detail_limit:
|
||||
break
|
||||
identity = _candidate_identity(candidate)
|
||||
if identity in seen:
|
||||
continue
|
||||
checked_index = min(len(seen) + 1, limit)
|
||||
|
||||
checked_index = min(len(seen) + 1, detail_limit)
|
||||
|
||||
# Throttling: sleep a bit between requests to stay under the rate limit
|
||||
if checked_index > 1:
|
||||
time.sleep(0.6)
|
||||
|
||||
_report_progress(
|
||||
progress_callback,
|
||||
"candidate",
|
||||
f"Resolving CivArchive candidate {checked_index}/{limit}",
|
||||
58 + (checked_index / max(1, limit)) * 30,
|
||||
f"Resolving CivArchive candidate {checked_index}/{detail_limit}",
|
||||
58 + (checked_index / max(1, detail_limit)) * 30,
|
||||
candidate_index=candidate_index,
|
||||
candidate_count=candidate_count,
|
||||
checked_candidate_count=checked_index,
|
||||
candidate_limit=limit,
|
||||
candidate_limit=detail_limit,
|
||||
)
|
||||
seen.add(identity)
|
||||
|
||||
@@ -1472,7 +2107,10 @@ def search_civarchive_for_file(
|
||||
best_confidence = confidence
|
||||
best_match = resolved
|
||||
|
||||
if confidence == 100.0 and base_model_matches:
|
||||
if confidence == 100.0 and (
|
||||
base_model_matches
|
||||
or _is_hash_verified_exact_match(resolved, confidence)
|
||||
):
|
||||
_search_cache[cache_key] = best_match
|
||||
_report_progress(
|
||||
progress_callback,
|
||||
@@ -1484,9 +2122,12 @@ def search_civarchive_for_file(
|
||||
)
|
||||
return best_match
|
||||
|
||||
if len(seen) >= limit:
|
||||
if len(seen) >= detail_limit:
|
||||
break
|
||||
|
||||
if len(seen) >= detail_limit:
|
||||
break
|
||||
|
||||
if best_match and (
|
||||
not base_model_context
|
||||
or _base_model_matches(best_match.get("base_model"), base_model_context)
|
||||
@@ -1507,8 +2148,11 @@ def search_civarchive_for_file(
|
||||
|
||||
if best_match and best_confidence < 40:
|
||||
best_match = None
|
||||
if best_match and base_model_context and not _base_model_matches(
|
||||
best_match.get("base_model"), base_model_context
|
||||
if (
|
||||
best_match
|
||||
and base_model_context
|
||||
and not _base_model_matches(best_match.get("base_model"), base_model_context)
|
||||
and not _is_hash_verified_exact_match(best_match, best_confidence)
|
||||
):
|
||||
best_match = None
|
||||
|
||||
@@ -1521,6 +2165,6 @@ def search_civarchive_for_file(
|
||||
found=bool(best_match),
|
||||
confidence=best_confidence if best_match else None,
|
||||
checked_candidate_count=len(seen),
|
||||
candidate_limit=limit,
|
||||
candidate_limit=detail_limit,
|
||||
)
|
||||
return best_match
|
||||
|
||||
@@ -182,6 +182,14 @@ CIVITAI_API_TYPE_MAP = {
|
||||
"embeddings": "TextualInversion",
|
||||
"upscaler": "Upscaler",
|
||||
"upscale_models": "Upscaler",
|
||||
"hypernetwork": "Hypernetwork",
|
||||
"hypernetworks": "Hypernetwork",
|
||||
"poses": "Poses",
|
||||
"wildcards": "Wildcards",
|
||||
"motion_module": "MotionModule",
|
||||
"motion_modules": "MotionModule",
|
||||
"aesthetic_gradient": "AestheticGradient",
|
||||
"aesthetic_gradients": "AestheticGradient",
|
||||
}
|
||||
|
||||
# Case-sensitive types for CivArchive search API (mapping filter support)
|
||||
@@ -201,6 +209,20 @@ CIVARCHIVE_API_TYPE_MAP = {
|
||||
"upscale_models": "Upscaler",
|
||||
"workflow": "Workflows",
|
||||
"workflows": "Workflows",
|
||||
"hypernetwork": "Hypernetwork",
|
||||
"hypernetworks": "Hypernetwork",
|
||||
"unet": "UNet",
|
||||
"diffusion_models": "UNet",
|
||||
"text_encoder": "TextEncoder",
|
||||
"text_encoders": "TextEncoder",
|
||||
"clip": "TextEncoder",
|
||||
"detection": "Detection",
|
||||
"wildcards": "Wildcards",
|
||||
"aesthetic_gradient": "AestheticGradient",
|
||||
"aesthetic_gradients": "AestheticGradient",
|
||||
"poses": "Poses",
|
||||
"motion_module": "MotionModule",
|
||||
"motion_modules": "MotionModule",
|
||||
}
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple, Callable
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -366,9 +366,12 @@ export const resolveDownloadMethods = {
|
||||
}
|
||||
|
||||
this.batchSearchCancelRequested = true;
|
||||
for (const job of Array.from(this.backgroundSearchJobs?.values?.() || [])) {
|
||||
this.cancelBackgroundSearchJob?.(job);
|
||||
}
|
||||
this.closeFooterMenus();
|
||||
this.updateBatchFooterButtons();
|
||||
this.showNotification('Stopping batch search after the current model...', 'info');
|
||||
this.showNotification('Stopping active search...', 'info');
|
||||
},
|
||||
|
||||
async downloadMissingBatch(mode = 'selected') {
|
||||
@@ -1591,6 +1594,7 @@ export const resolveDownloadMethods = {
|
||||
missing,
|
||||
startedAt: Date.now(),
|
||||
sourceControllers: new Map(),
|
||||
sourceProgressIds: new Map(),
|
||||
cancelledSources: new Set(),
|
||||
promise: null
|
||||
};
|
||||
@@ -1600,6 +1604,10 @@ export const resolveDownloadMethods = {
|
||||
state.lastAttemptFound = null;
|
||||
state.lastAttemptError = null;
|
||||
state.lastAttemptBaseModelContext = baseModelContext;
|
||||
if (forceSearch) {
|
||||
state.results = this.clearSearchResultsForSources?.(state.results || {}, sourceIds)
|
||||
|| this.createEmptySearchState().results;
|
||||
}
|
||||
state.sourceProgress = {};
|
||||
for (const source of sourceIds) {
|
||||
this.setSourceProgress(state, source, {
|
||||
@@ -1675,6 +1683,9 @@ export const resolveDownloadMethods = {
|
||||
if (sourceController && currentJob?.runId === searchRunId) {
|
||||
currentJob.sourceControllers?.set(source, sourceController);
|
||||
}
|
||||
if (currentJob?.runId === searchRunId) {
|
||||
currentJob.sourceProgressIds?.set(source, progressId);
|
||||
}
|
||||
this.startBackendSearchProgressPolling?.(state, missing, source, searchRunId, progressId, { workflowKey });
|
||||
|
||||
try {
|
||||
@@ -1792,6 +1803,7 @@ export const resolveDownloadMethods = {
|
||||
const cleanupJob = this.backgroundSearchJobs.get(backgroundJobKey);
|
||||
if (cleanupJob?.runId === searchRunId) {
|
||||
cleanupJob.sourceControllers?.delete(source);
|
||||
cleanupJob.sourceProgressIds?.delete(source);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2030,7 +2042,7 @@ export const resolveDownloadMethods = {
|
||||
searchBtn.dataset.mlSearchBound = 'true';
|
||||
searchBtn.addEventListener('click', () => {
|
||||
this.searchOnline(missing, {
|
||||
forceSearch: this.hasSearchResultsForMissing(missing)
|
||||
forceSearch: this.hasSearchAttemptForMissing?.(missing) || this.hasSearchResultsForMissing(missing)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2273,6 +2273,9 @@ export const downloadTargetMethods = {
|
||||
}
|
||||
this.clearAllSearchProgressTimers();
|
||||
for (const job of this.backgroundSearchJobs?.values?.() || []) {
|
||||
for (const progressId of job.sourceProgressIds?.values?.() || []) {
|
||||
this.cancelBackendSearchProgress?.(progressId);
|
||||
}
|
||||
for (const controller of job.sourceControllers?.values?.() || []) {
|
||||
try {
|
||||
controller.abort();
|
||||
|
||||
@@ -564,9 +564,6 @@ export const searchPanelMethods = {
|
||||
*/
|
||||
mergeSearchResults(existingResults = {}, newResults = {}, { searchedAt = null, forceRefresh = false } = {}) {
|
||||
const searchedSources = new Set(Array.isArray(newResults.searched_sources) ? newResults.searched_sources : []);
|
||||
const sourceErrors = newResults.source_errors && typeof newResults.source_errors === 'object'
|
||||
? newResults.source_errors
|
||||
: {};
|
||||
const pickResult = (source) => {
|
||||
if (newResults[source]) {
|
||||
const existingTimestamp = this.getSearchResultTimestamp(existingResults[source]);
|
||||
@@ -580,20 +577,28 @@ export const searchPanelMethods = {
|
||||
}
|
||||
const sourceWasSearched = searchedSources.has(source)
|
||||
|| (searchedSources.has('local') && (source === 'popular' || source === 'model_list'));
|
||||
const sourceHadError = sourceErrors[source]
|
||||
|| (sourceErrors.local && (source === 'popular' || source === 'model_list'));
|
||||
if (forceRefresh && sourceWasSearched && !sourceHadError) {
|
||||
if (forceRefresh && sourceWasSearched) {
|
||||
return null;
|
||||
}
|
||||
return existingResults[source] || null;
|
||||
};
|
||||
const hashSourcesToClear = forceRefresh
|
||||
? this.getHashLookupSourcesForSearchSources(Array.from(searchedSources))
|
||||
: new Set();
|
||||
const existingHashMatches = Array.isArray(existingResults.local_hash_matches)
|
||||
? existingResults.local_hash_matches.filter(match => {
|
||||
if (!hashSourcesToClear.size) return true;
|
||||
const source = String(match?.hash_lookup_source || '').trim();
|
||||
return source && !hashSourcesToClear.has(source);
|
||||
})
|
||||
: [];
|
||||
const localHashMatches = this.mergeLocalMatches
|
||||
? this.mergeLocalMatches(
|
||||
Array.isArray(existingResults.local_hash_matches) ? existingResults.local_hash_matches : [],
|
||||
existingHashMatches,
|
||||
Array.isArray(newResults.local_hash_matches) ? newResults.local_hash_matches : []
|
||||
)
|
||||
: [
|
||||
...(Array.isArray(existingResults.local_hash_matches) ? existingResults.local_hash_matches : []),
|
||||
...existingHashMatches,
|
||||
...(Array.isArray(newResults.local_hash_matches) ? newResults.local_hash_matches : [])
|
||||
];
|
||||
|
||||
@@ -776,6 +781,65 @@ export const searchPanelMethods = {
|
||||
return this.hasSearchResults(state?.results || {});
|
||||
},
|
||||
|
||||
hasSearchAttemptForMissing(missing = {}) {
|
||||
if (missing?.download_source?.url) return true;
|
||||
const state = this.searchResultCache?.get(this.getMissingSearchKey(missing));
|
||||
return this.hasRenderableSearchState?.(state || {}) || this.hasSearchResults(state?.results || {});
|
||||
},
|
||||
|
||||
getSearchResultKeysForSources(sources = []) {
|
||||
const normalized = new Set((Array.isArray(sources) ? sources : [sources])
|
||||
.map(source => String(source || '').trim())
|
||||
.filter(Boolean));
|
||||
if (normalized.has('all')) {
|
||||
return ['popular', 'model_list', 'huggingface', 'civitai', 'civarchive', 'lora_manager_archive'];
|
||||
}
|
||||
|
||||
const keys = new Set();
|
||||
for (const source of normalized) {
|
||||
if (source === 'local') {
|
||||
keys.add('popular');
|
||||
keys.add('model_list');
|
||||
} else if (source) {
|
||||
keys.add(source);
|
||||
}
|
||||
}
|
||||
return Array.from(keys);
|
||||
},
|
||||
|
||||
getHashLookupSourcesForSearchSources(sources = []) {
|
||||
const normalized = new Set((Array.isArray(sources) ? sources : [sources])
|
||||
.map(source => String(source || '').trim())
|
||||
.filter(Boolean));
|
||||
const hashSources = ['huggingface', 'civitai', 'civarchive'];
|
||||
if (normalized.has('all')) return new Set(hashSources);
|
||||
return new Set(hashSources.filter(source => normalized.has(source)));
|
||||
},
|
||||
|
||||
clearSearchResultsForSources(results = {}, sources = []) {
|
||||
const nextResults = {
|
||||
popular: results.popular || null,
|
||||
model_list: results.model_list || null,
|
||||
huggingface: results.huggingface || null,
|
||||
civitai: results.civitai || null,
|
||||
civarchive: results.civarchive || null,
|
||||
lora_manager_archive: results.lora_manager_archive || null,
|
||||
local_hash_matches: Array.isArray(results.local_hash_matches) ? results.local_hash_matches : []
|
||||
};
|
||||
for (const key of this.getSearchResultKeysForSources(sources)) {
|
||||
if (key in nextResults) nextResults[key] = null;
|
||||
}
|
||||
|
||||
const hashSourcesToClear = this.getHashLookupSourcesForSearchSources(sources);
|
||||
if (hashSourcesToClear.size) {
|
||||
nextResults.local_hash_matches = nextResults.local_hash_matches.filter(match => {
|
||||
const source = String(match?.hash_lookup_source || '').trim();
|
||||
return source && !hashSourcesToClear.has(source);
|
||||
});
|
||||
}
|
||||
return nextResults;
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert source ids to readable labels
|
||||
*/
|
||||
@@ -1265,6 +1329,17 @@ export const searchPanelMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
cancelBackendSearchProgress(progressId = '') {
|
||||
const id = String(progressId || '').trim();
|
||||
if (!id) return;
|
||||
this.fetchJson(`/model_resolver/search-cancel/${encodeURIComponent(id)}`, {
|
||||
method: 'POST',
|
||||
silent: true
|
||||
}, 'Cancel search').catch(error => {
|
||||
console.warn('Model Resolver: backend search cancel failed', error);
|
||||
});
|
||||
},
|
||||
|
||||
cancelSearchSource(missing, source, runId = '', { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
|
||||
if (!missing || !source || !runId) return false;
|
||||
|
||||
@@ -1275,6 +1350,8 @@ export const searchPanelMethods = {
|
||||
|
||||
job.cancelledSources = job.cancelledSources || new Set();
|
||||
job.cancelledSources.add(source);
|
||||
const progressId = job.sourceProgressIds?.get(source) || '';
|
||||
this.cancelBackendSearchProgress(progressId);
|
||||
const controller = job.sourceControllers?.get(source);
|
||||
if (controller && !controller.signal?.aborted) {
|
||||
controller.abort();
|
||||
@@ -1305,6 +1382,22 @@ export const searchPanelMethods = {
|
||||
return true;
|
||||
},
|
||||
|
||||
cancelBackgroundSearchJob(job = null) {
|
||||
if (!job?.missing || !job.runId) return false;
|
||||
const sources = new Set([
|
||||
...Array.from(job.sourceControllers?.keys?.() || []),
|
||||
...Array.from(job.sourceProgressIds?.keys?.() || []),
|
||||
...Object.keys(this.getSearchStateForWorkflow(job.workflowKey, job.missing)?.sourceProgress || {})
|
||||
]);
|
||||
let cancelled = false;
|
||||
for (const source of sources) {
|
||||
cancelled = this.cancelSearchSource(job.missing, source, job.runId, {
|
||||
workflowKey: job.workflowKey || this.getWorkflowScopedQueueKey()
|
||||
}) || cancelled;
|
||||
}
|
||||
return cancelled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update source selector buttons and helper text for one card
|
||||
*/
|
||||
|
||||
@@ -1518,7 +1518,7 @@ export const missingBrowserMethods = {
|
||||
// Has perfect local match - download not needed, but allow online re-check.
|
||||
html += `<div class="mr-download-section">`;
|
||||
html += this.renderSearchControls(missing, {
|
||||
buttonText: this.hasSearchResultsForMissing(missing) ? 'Search Again' : 'Search Online'
|
||||
buttonText: this.hasSearchAttemptForMissing?.(missing) ? 'Search Again' : 'Search Online'
|
||||
});
|
||||
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
|
||||
html += `</div>`;
|
||||
|
||||
Reference in New Issue
Block a user