2453 lines
81 KiB
Python
2453 lines
81 KiB
Python
"""
|
|
CivArchive Source Module
|
|
|
|
Search and resolve models from CivArchive.
|
|
"""
|
|
|
|
import html
|
|
import json
|
|
import re
|
|
import time
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
from urllib.parse import urljoin, urlparse
|
|
|
|
import requests
|
|
|
|
from ..log_system import create_module_logger
|
|
from ..matcher import (
|
|
base_model_matches as _base_model_matches,
|
|
)
|
|
from ..matcher import (
|
|
build_filename_search_queries,
|
|
calculate_archived_model_confidence,
|
|
calculate_model_title_confidence,
|
|
should_update_best_match,
|
|
)
|
|
from ..matcher import (
|
|
has_known_model_extension as _has_known_model_extension,
|
|
)
|
|
from ..matcher import (
|
|
normalize_base_model as _normalize_base_model,
|
|
)
|
|
from ..network_utils import host_matches_domain, request_source_response
|
|
from ..path_utils import get_filename_from_path
|
|
from ..progress import get_progress_reporter
|
|
from ..type_utils import (
|
|
DEFAULT_BROWSER_USER_AGENT,
|
|
clear_remote_size_cache,
|
|
extract_file_size,
|
|
extract_trained_words,
|
|
fetch_remote_file_size_cached,
|
|
looks_like_model_file,
|
|
normalize_alphanumeric_key,
|
|
normalize_model_file_info,
|
|
normalize_model_image,
|
|
normalize_sha256,
|
|
prepare_remote_size_probe_url,
|
|
resolve_model_category,
|
|
select_primary_model_file,
|
|
to_int,
|
|
)
|
|
from .common import (
|
|
build_custom_url_result,
|
|
build_unified_search_result,
|
|
is_remote_link_marked_dead,
|
|
)
|
|
|
|
log = create_module_logger(__name__)
|
|
|
|
|
|
CIVARCHIVE_BASE_URL = "https://civarchive.com"
|
|
CIVARCHIVE_API_URL = f"{CIVARCHIVE_BASE_URL}/api"
|
|
CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
|
"https://civitai.com/api/download/",
|
|
"https://civitai.red/api/download/",
|
|
)
|
|
_ARCHIVE_DOWNLOAD_URL_OPTIONS = {
|
|
"prioritize_civitai_last": True,
|
|
"download_url_keys": ("downloadUrl",),
|
|
}
|
|
_NORMALIZED_DOWNLOAD_URL_OPTIONS = {
|
|
"skip_file_if_dead": True,
|
|
"check_download_urls_list": True,
|
|
"download_url_keys": ("download_url", "downloadUrl"),
|
|
}
|
|
|
|
DEFAULT_CIVARCHIVE_CANDIDATE_LIMIT = 10
|
|
MAX_CIVARCHIVE_CANDIDATE_LIMIT = 30
|
|
SEARCH_RESULT_DETAIL_LIMIT = 5
|
|
HASH_PAGE_MODEL_LINK_LIMIT = 5
|
|
CIVARCHIVE_FAILURE_MESSAGES = {
|
|
"timeout": (
|
|
"CivArchive did not respond in time. It may be temporarily overloaded. "
|
|
"Please try again."
|
|
),
|
|
"network_error": (
|
|
"CivArchive could not be reached. It may be temporarily unavailable. "
|
|
"Please try again."
|
|
),
|
|
"provider_unavailable": (
|
|
"CivArchive may be overloaded or temporarily unavailable. "
|
|
"Please try again."
|
|
),
|
|
"rate_limited": "CivArchive rate limit was reached. Please try again later.",
|
|
"not_found": "CivArchive did not find a matching page.",
|
|
"provider_rejected": "CivArchive rejected the search request.",
|
|
}
|
|
CIVARCHIVE_SIZE_PROBE_DOMAINS = (
|
|
"huggingface.co",
|
|
"civarchive.com",
|
|
"civitai.com",
|
|
"civitai.red",
|
|
"modelscope.ai",
|
|
"modelscope.cn",
|
|
)
|
|
CIVARCHIVE_SIZE_PROBE_PRIORITY = (
|
|
"huggingface.co",
|
|
"civarchive.com",
|
|
"civitai.com",
|
|
"civitai.red",
|
|
"modelscope.ai",
|
|
"modelscope.cn",
|
|
)
|
|
|
|
|
|
|
|
_search_cache: Dict[str, Any] = {}
|
|
|
|
REQUEST_HEADERS = {
|
|
"accept": "application/json,text/html;q=0.9,*/*;q=0.8",
|
|
"user-agent": DEFAULT_BROWSER_USER_AGENT,
|
|
}
|
|
|
|
# Imported CIVARCHIVE_API_TYPE_MAP
|
|
|
|
|
|
class CivArchiveSearchError(Exception):
|
|
"""Raised when CivArchive cannot complete a search request."""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
code: str = "provider_unavailable",
|
|
http_status: Optional[int] = None,
|
|
retryable: bool = True,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.http_status = http_status
|
|
self.retryable = retryable
|
|
|
|
def as_status(self) -> Dict[str, Any]:
|
|
"""Return a safe provider status payload for search responses."""
|
|
if self.code == "rate_limited":
|
|
state = "rate_limited"
|
|
elif self.code == "not_found":
|
|
state = "not_found"
|
|
elif self.retryable:
|
|
state = "unavailable"
|
|
else:
|
|
state = "error"
|
|
|
|
return {
|
|
"state": state,
|
|
"code": self.code,
|
|
"retryable": self.retryable,
|
|
"http_status": self.http_status,
|
|
"message": CIVARCHIVE_FAILURE_MESSAGES.get(
|
|
self.code,
|
|
"CivArchive search failed. Please try again later.",
|
|
),
|
|
}
|
|
|
|
|
|
def _classify_civarchive_http_status(status_code: int) -> Dict[str, Any]:
|
|
"""Classify an HTTP response for user-facing provider status handling."""
|
|
try:
|
|
normalized_status = int(status_code)
|
|
except (TypeError, ValueError):
|
|
return {"code": "provider_unavailable", "retryable": True}
|
|
|
|
if normalized_status == 408:
|
|
return {"code": "timeout", "retryable": True}
|
|
if normalized_status == 429:
|
|
return {"code": "rate_limited", "retryable": True}
|
|
if normalized_status == 404:
|
|
return {"code": "not_found", "retryable": False}
|
|
if 400 <= normalized_status < 500:
|
|
return {"code": "provider_rejected", "retryable": False}
|
|
return {"code": "provider_unavailable", "retryable": True}
|
|
|
|
|
|
def clear_search_cache():
|
|
"""Clear cached CivArchive search results."""
|
|
_search_cache.clear()
|
|
clear_remote_size_cache()
|
|
|
|
|
|
_report_progress = get_progress_reporter("CivArchive progress callback")
|
|
|
|
|
|
def is_civarchive_available() -> bool:
|
|
"""Return True when the CivArchive provider can be offered."""
|
|
return True
|
|
|
|
|
|
# _extract_file_size_bytes removed in favor of extract_file_size from type_utils
|
|
|
|
|
|
|
|
def _normalize_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
return {}
|
|
data = payload.get("data")
|
|
return data if isinstance(data, dict) else payload
|
|
|
|
|
|
def _extract_next_data(html_text: str) -> Dict[str, Any]:
|
|
match = re.search(
|
|
r'<script\b(?=[^>]*\bid=["\']__NEXT_DATA__["\'])(?=[^>]*\btype=["\']application/json["\'])[^>]*>(.*?)</script>',
|
|
html_text,
|
|
flags=re.DOTALL,
|
|
)
|
|
if not match:
|
|
return {}
|
|
|
|
try:
|
|
return json.loads(html.unescape(match.group(1)))
|
|
except Exception as e:
|
|
log.warning(f"CivArchive search JSON parse failed: {e}")
|
|
return {}
|
|
|
|
|
|
def _request_json(
|
|
path: str,
|
|
params: Optional[Dict[str, Any]] = None,
|
|
timeout: int = 20,
|
|
) -> Optional[Dict[str, Any]]:
|
|
from ..network_utils import execute_provider_json_request
|
|
url = f"{CIVARCHIVE_API_URL}{path}"
|
|
return execute_provider_json_request(
|
|
"CivArchive API",
|
|
url,
|
|
params=params,
|
|
headers=REQUEST_HEADERS,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
|
|
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)
|
|
response = request_source_response(
|
|
url,
|
|
method="GET",
|
|
headers=REQUEST_HEADERS,
|
|
params=params,
|
|
timeout=timeout,
|
|
log_name="CivArchive page",
|
|
)
|
|
if response is None:
|
|
return None
|
|
|
|
if response.status_code != 200:
|
|
log.debug(f"CivArchive page returned {response.status_code}: url={url}")
|
|
return None
|
|
|
|
return response.text
|
|
|
|
|
|
def _normalize_civarchive_type(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
text = str(value).strip()
|
|
mapped = resolve_model_category(text, target_format="civarchive")
|
|
return normalize_alphanumeric_key(mapped)
|
|
|
|
|
|
def _extract_hash_page_model_cards(next_data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
page_props = next_data.get("props", {}).get("pageProps", {})
|
|
models = page_props.get("models") if isinstance(page_props, dict) else []
|
|
if not isinstance(models, list):
|
|
models = [models]
|
|
|
|
cards: List[Dict[str, Any]] = []
|
|
for model in models:
|
|
if not isinstance(model, dict):
|
|
continue
|
|
|
|
version = model.get("version") if isinstance(model.get("version"), dict) else {}
|
|
model_id = to_int(
|
|
model.get("id")
|
|
or model.get("model_id")
|
|
or model.get("modelId")
|
|
or version.get("model_id")
|
|
or version.get("modelId")
|
|
)
|
|
version_id = to_int(
|
|
version.get("id")
|
|
or model.get("model_version_id")
|
|
or model.get("modelVersionId")
|
|
or model.get("version_id")
|
|
)
|
|
if not model_id:
|
|
continue
|
|
|
|
cards.append(
|
|
{
|
|
"model_id": model_id,
|
|
"version_id": version_id,
|
|
"type": model.get("type"),
|
|
"name": model.get("name"),
|
|
}
|
|
)
|
|
|
|
return cards
|
|
|
|
|
|
def _extract_model_links_from_html(html_text: str) -> List[Dict[str, Any]]:
|
|
text = _normalize_embedded_html_text(html_text)
|
|
if not text:
|
|
return []
|
|
|
|
next_data = _extract_next_data(html_text)
|
|
model_cards = _extract_hash_page_model_cards(next_data)
|
|
card_meta_by_key = {
|
|
(card.get("model_id"), card.get("version_id")): card
|
|
for card in model_cards
|
|
if card.get("model_id")
|
|
}
|
|
links: List[Dict[str, Any]] = []
|
|
links_by_key: Dict[Tuple[Optional[int], Optional[int]], Dict[str, Any]] = {}
|
|
seen = set()
|
|
|
|
def add_link(model_id: Any, version_id: Any = None, **metadata: Any) -> None:
|
|
resolved_model_id = to_int(model_id)
|
|
resolved_version_id = to_int(version_id) if version_id else None
|
|
key = (resolved_model_id, resolved_version_id)
|
|
if not resolved_model_id:
|
|
return
|
|
card_meta = card_meta_by_key.get(key) or {}
|
|
if key in seen:
|
|
existing = links_by_key.get(key)
|
|
if existing:
|
|
for field in ("type", "name"):
|
|
value = metadata.get(field) or card_meta.get(field)
|
|
if value and not existing.get(field):
|
|
existing[field] = value
|
|
return
|
|
seen.add(key)
|
|
link = {
|
|
"model_id": resolved_model_id,
|
|
"version_id": resolved_version_id,
|
|
}
|
|
for field in ("type", "name"):
|
|
value = metadata.get(field) or card_meta.get(field)
|
|
if value:
|
|
link[field] = value
|
|
links.append(link)
|
|
links_by_key[key] = link
|
|
|
|
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(next_data)
|
|
|
|
for match in pattern.finditer(text):
|
|
add_link(match.group(1), match.group(2))
|
|
|
|
for card in model_cards:
|
|
add_link(
|
|
card.get("model_id"),
|
|
card.get("version_id"),
|
|
type=card.get("type"),
|
|
name=card.get("name"),
|
|
)
|
|
|
|
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 _prefer_model_links_for_expected_type(
|
|
links: List[Dict[str, Any]],
|
|
expected_model_type: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
expected_type = _normalize_civarchive_type(expected_model_type)
|
|
if not expected_type:
|
|
return links
|
|
|
|
def is_expected(link: Dict[str, Any]) -> bool:
|
|
return _normalize_civarchive_type(link.get("type")) == expected_type
|
|
|
|
if not any(is_expected(link) for link in links):
|
|
return links
|
|
|
|
ordered = sorted(
|
|
enumerate(links),
|
|
key=lambda item: (0 if is_expected(item[1]) else 1, item[0]),
|
|
)
|
|
return [link for _, link in ordered]
|
|
|
|
|
|
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 _download_url_looks_like_model_file(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
|
|
page_text = _request_page_text(
|
|
f"/models/{model_id}",
|
|
params=params,
|
|
timeout=timeout,
|
|
)
|
|
if not page_text:
|
|
return None
|
|
|
|
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"]
|
|
if isinstance(page_props.get("model"), dict):
|
|
return page_props["model"]
|
|
return page_props if isinstance(page_props, dict) and page_props else None
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _search_page(
|
|
query: str,
|
|
model_type: Optional[str] = None,
|
|
timeout: int = 20,
|
|
) -> List[Dict[str, Any]]:
|
|
params: Dict[str, Any] = {
|
|
"q": query,
|
|
"rating": "all",
|
|
"platform": "civitai,huggingface,malcolmrey,modelscope,modelscope_cn",
|
|
"sort": "relevance",
|
|
}
|
|
# 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 = request_source_response(
|
|
f"{CIVARCHIVE_BASE_URL}/search",
|
|
method="GET",
|
|
headers=REQUEST_HEADERS,
|
|
params=params,
|
|
timeout=timeout,
|
|
log_name="CivArchive search",
|
|
raise_on_error=True,
|
|
)
|
|
except requests.Timeout as error:
|
|
raise CivArchiveSearchError(
|
|
"CivArchive search request timed out",
|
|
code="timeout",
|
|
retryable=True,
|
|
) from error
|
|
except requests.RequestException as error:
|
|
raise CivArchiveSearchError(
|
|
"CivArchive search request failed (network error)",
|
|
code="network_error",
|
|
retryable=True,
|
|
) from error
|
|
|
|
if response is None:
|
|
raise CivArchiveSearchError(
|
|
"CivArchive search request failed (network error or timeout)",
|
|
code="network_error",
|
|
retryable=True,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
failure = _classify_civarchive_http_status(response.status_code)
|
|
log.warning(
|
|
f"CivArchive search returned {response.status_code}: query={query}"
|
|
)
|
|
raise CivArchiveSearchError(
|
|
f"HTTP {response.status_code}",
|
|
code=failure["code"],
|
|
http_status=response.status_code,
|
|
retryable=failure["retryable"],
|
|
)
|
|
|
|
next_data = _extract_next_data(response.text)
|
|
results = (
|
|
next_data.get("props", {})
|
|
.get("pageProps", {})
|
|
.get("data", {})
|
|
.get("results", [])
|
|
)
|
|
if not isinstance(results, list):
|
|
return []
|
|
|
|
log.info(
|
|
f"CivArchive search returned {len(results)} candidates for query={query}"
|
|
)
|
|
return [result for result in results if isinstance(result, dict)]
|
|
|
|
|
|
def parse_civarchive_url(url: str) -> Optional[Dict[str, Any]]:
|
|
"""Parse CivArchive URLs into sha256 or model/version identifiers."""
|
|
from ..type_utils import parse_provider_model_url
|
|
if not url:
|
|
return None
|
|
full_url = urljoin(CIVARCHIVE_BASE_URL, url)
|
|
return parse_provider_model_url(full_url, ["civarchive.com"])
|
|
|
|
|
|
def _extract_sha256(value: str) -> Optional[str]:
|
|
if not value:
|
|
return None
|
|
normalized = normalize_sha256(value)
|
|
if normalized:
|
|
return normalized
|
|
parsed = parse_civarchive_url(value)
|
|
return parsed.get("sha256") if parsed else None
|
|
|
|
|
|
|
|
def _extract_model_context(
|
|
payload: Dict[str, Any],
|
|
) -> Tuple[Dict[str, Any], Dict[str, Any], List[Dict[str, Any]]]:
|
|
data = _normalize_payload(payload)
|
|
if not data:
|
|
return {}, {}, []
|
|
|
|
top_files = data.get("files") or []
|
|
if not isinstance(top_files, list):
|
|
top_files = [top_files]
|
|
|
|
model_block = data.get("model")
|
|
if isinstance(model_block, dict):
|
|
context = {k: v for k, v in model_block.items() if k != "version"}
|
|
version = model_block.get("version")
|
|
else:
|
|
context = {k: v for k, v in data.items() if k not in {"version", "files"}}
|
|
version = data.get("version")
|
|
|
|
if not isinstance(version, dict):
|
|
version = data.get("version") if isinstance(data.get("version"), dict) else {}
|
|
|
|
return context, version, [item for item in top_files if isinstance(item, dict)]
|
|
|
|
|
|
def _normalize_download_url(url: Any) -> Optional[str]:
|
|
if not isinstance(url, str) or not url.strip():
|
|
return None
|
|
|
|
value = url.strip()
|
|
if value.startswith("//"):
|
|
return f"https:{value}"
|
|
if value.startswith("http://") or value.startswith("https://"):
|
|
return value
|
|
if value.startswith("/api/download/"):
|
|
return urljoin(CIVARCHIVE_BASE_URL, value)
|
|
return None
|
|
|
|
|
|
def _download_url_looks_like_model_file(
|
|
url: Any,
|
|
expected_filename: str = "",
|
|
) -> bool:
|
|
normalized = _normalize_download_url(url)
|
|
if not normalized:
|
|
return False
|
|
return looks_like_model_file(normalized, expected_filename)
|
|
|
|
def _fetch_remote_file_size_bytes(url: Any, timeout: int = 15) -> Optional[int]:
|
|
normalized = _normalize_download_url(url)
|
|
if not normalized:
|
|
return None
|
|
probe_url = prepare_remote_size_probe_url(
|
|
normalized,
|
|
list(CIVARCHIVE_SIZE_PROBE_DOMAINS),
|
|
)
|
|
if not probe_url:
|
|
return None
|
|
return fetch_remote_file_size_cached(probe_url, headers=REQUEST_HEADERS, timeout=timeout)
|
|
|
|
|
|
def _remote_size_probe_priority(url: Any) -> int:
|
|
try:
|
|
host = urlparse(str(url or "")).hostname
|
|
except ValueError:
|
|
return len(CIVARCHIVE_SIZE_PROBE_PRIORITY)
|
|
|
|
for priority, domain in enumerate(CIVARCHIVE_SIZE_PROBE_PRIORITY):
|
|
if host_matches_domain(host, domain):
|
|
return priority
|
|
return len(CIVARCHIVE_SIZE_PROBE_PRIORITY)
|
|
|
|
|
|
|
|
def _resolve_file_size_bytes(
|
|
file_info: Dict[str, Any],
|
|
download_urls: Optional[List[str]] = None,
|
|
) -> Optional[int]:
|
|
size = extract_file_size(file_info)
|
|
if size:
|
|
return size
|
|
|
|
urls = download_urls if download_urls is not None else _collect_download_urls_unified(
|
|
file_info,
|
|
**_NORMALIZED_DOWNLOAD_URL_OPTIONS,
|
|
)
|
|
for url in sorted(urls, key=_remote_size_probe_priority):
|
|
size = _fetch_remote_file_size_bytes(url)
|
|
if size:
|
|
return size
|
|
|
|
return None
|
|
|
|
|
|
def _mirror_from_top_file(file_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
url = _normalize_download_url(file_data.get("url") or file_data.get("downloadUrl"))
|
|
if not url:
|
|
return None
|
|
|
|
hashes = file_data.get("hashes") if isinstance(file_data.get("hashes"), dict) else {}
|
|
sha256 = (
|
|
file_data.get("sha256")
|
|
or file_data.get("hash")
|
|
or hashes.get("SHA256")
|
|
or hashes.get("sha256")
|
|
)
|
|
|
|
return {
|
|
"filename": file_data.get("filename") or file_data.get("name"),
|
|
"url": url,
|
|
"source": file_data.get("source") or file_data.get("platform"),
|
|
"sha256": sha256,
|
|
"hash": sha256,
|
|
"model_id": file_data.get("model_id") or file_data.get("modelId"),
|
|
"model_version_id": file_data.get("model_version_id")
|
|
or file_data.get("modelVersionId"),
|
|
"sizeKB": file_data.get("sizeKB") or file_data.get("size_kb"),
|
|
"sizeBytes": file_data.get("sizeBytes")
|
|
or file_data.get("size_bytes")
|
|
or file_data.get("fileSize")
|
|
or file_data.get("file_size")
|
|
or file_data.get("bytes"),
|
|
"size": file_data.get("size"),
|
|
"deletedAt": file_data.get("deletedAt") or file_data.get("deleted_at"),
|
|
"is_dead": file_data.get("is_dead")
|
|
or file_data.get("isDead")
|
|
or file_data.get("likelyDead")
|
|
or file_data.get("likely_dead")
|
|
or file_data.get("dead"),
|
|
"is_gated": file_data.get("is_gated"),
|
|
"is_paid": file_data.get("is_paid"),
|
|
}
|
|
|
|
|
|
def _transform_file_entry(file_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
mirrors = file_data.get("mirrors") or []
|
|
if not isinstance(mirrors, list):
|
|
mirrors = [mirrors]
|
|
|
|
transformed_mirrors = []
|
|
for mirror in mirrors:
|
|
if not isinstance(mirror, dict):
|
|
continue
|
|
mirror_url = _normalize_download_url(mirror.get("url"))
|
|
if mirror_url:
|
|
mirror = dict(mirror)
|
|
mirror["url"] = mirror_url
|
|
transformed_mirrors.append(mirror)
|
|
|
|
download_url = None
|
|
if not is_remote_link_marked_dead(file_data):
|
|
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:
|
|
for mirror in transformed_mirrors:
|
|
if not is_remote_link_marked_dead(mirror) and mirror.get("url"):
|
|
download_url = mirror["url"]
|
|
break
|
|
|
|
return {
|
|
"id": file_data.get("id"),
|
|
"name": name,
|
|
"type": file_data.get("type"),
|
|
"sizeKB": file_data.get("sizeKB") or file_data.get("size_kb"),
|
|
"sizeBytes": file_data.get("sizeBytes")
|
|
or file_data.get("size_bytes")
|
|
or file_data.get("fileSize")
|
|
or file_data.get("file_size")
|
|
or file_data.get("bytes"),
|
|
"size": file_data.get("size"),
|
|
"downloadUrl": download_url,
|
|
"primary": bool(file_data.get("primary", file_data.get("is_primary", False))),
|
|
"mirrors": transformed_mirrors,
|
|
"hashes": file_data.get("hashes", {}),
|
|
"sha256": file_data.get("sha256"),
|
|
"metadata": file_data.get("metadata"),
|
|
"modelId": file_data.get("modelId") or file_data.get("model_id"),
|
|
"modelVersionId": file_data.get("modelVersionId")
|
|
or file_data.get("model_version_id"),
|
|
}
|
|
|
|
|
|
def _same_file(file_info: Dict[str, Any], mirror: Dict[str, Any]) -> bool:
|
|
file_name = str(file_info.get("name") or "").lower()
|
|
mirror_name = str(mirror.get("filename") or "").lower()
|
|
if file_name and mirror_name and file_name == mirror_name:
|
|
return True
|
|
|
|
file_sha = (
|
|
file_info.get("sha256")
|
|
or (file_info.get("hashes") or {}).get("SHA256")
|
|
or (file_info.get("hashes") or {}).get("sha256")
|
|
)
|
|
mirror_sha = mirror.get("sha256") or mirror.get("hash")
|
|
return bool(file_sha and mirror_sha and str(file_sha).lower() == str(mirror_sha).lower())
|
|
|
|
|
|
def _merge_top_file_mirrors(
|
|
files: List[Dict[str, Any]], top_files: List[Dict[str, Any]]
|
|
) -> List[Dict[str, Any]]:
|
|
if not top_files:
|
|
return files
|
|
|
|
if not files:
|
|
merged = []
|
|
for top_file in top_files:
|
|
mirror = _mirror_from_top_file(top_file)
|
|
if not mirror:
|
|
continue
|
|
merged.append(
|
|
{
|
|
"id": top_file.get("id"),
|
|
"name": mirror.get("filename"),
|
|
"type": top_file.get("type") or "Model",
|
|
"sizeKB": top_file.get("sizeKB") or top_file.get("size_kb"),
|
|
"sizeBytes": top_file.get("sizeBytes")
|
|
or top_file.get("size_bytes")
|
|
or top_file.get("fileSize")
|
|
or top_file.get("file_size")
|
|
or top_file.get("bytes"),
|
|
"size": top_file.get("size"),
|
|
"downloadUrl": mirror.get("url"),
|
|
"primary": True,
|
|
"mirrors": [mirror],
|
|
"hashes": {"SHA256": top_file.get("sha256")}
|
|
if top_file.get("sha256")
|
|
else {},
|
|
"sha256": top_file.get("sha256"),
|
|
"modelId": top_file.get("model_id") or top_file.get("modelId"),
|
|
"modelVersionId": top_file.get("model_version_id")
|
|
or top_file.get("modelVersionId"),
|
|
}
|
|
)
|
|
return merged
|
|
|
|
for top_file in top_files:
|
|
mirror = _mirror_from_top_file(top_file)
|
|
if not mirror:
|
|
continue
|
|
|
|
target = next((file_info for file_info in files if _same_file(file_info, mirror)), None)
|
|
if target is None:
|
|
target = files[0]
|
|
|
|
mirrors = target.setdefault("mirrors", [])
|
|
mirror_url = mirror.get("url")
|
|
if mirror_url and not any(existing.get("url") == mirror_url for existing in mirrors):
|
|
mirrors.append(mirror)
|
|
if not extract_file_size(target):
|
|
target_size_bytes = extract_file_size(top_file)
|
|
if target_size_bytes:
|
|
target["sizeBytes"] = target_size_bytes
|
|
|
|
return files
|
|
|
|
|
|
def _extract_version_files(
|
|
version: Dict[str, Any], top_files: Optional[List[Dict[str, Any]]] = None
|
|
) -> List[Dict[str, Any]]:
|
|
raw_files = version.get("files") or []
|
|
if not isinstance(raw_files, list):
|
|
raw_files = [raw_files]
|
|
|
|
files = [
|
|
_transform_file_entry(file_data)
|
|
for file_data in raw_files
|
|
if isinstance(file_data, dict)
|
|
]
|
|
return _merge_top_file_mirrors(files, top_files or [])
|
|
|
|
|
|
def _select_primary_file(files: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
return select_primary_model_file(files)
|
|
|
|
|
|
def _collect_download_urls_unified(
|
|
file_info: Dict[str, Any],
|
|
prioritize_civitai_last: bool = False,
|
|
skip_file_if_dead: bool = False,
|
|
check_download_urls_list: bool = False,
|
|
download_url_keys: Tuple[str, ...] = ("downloadUrl",),
|
|
) -> List[str]:
|
|
urls: List[str] = []
|
|
if skip_file_if_dead and is_remote_link_marked_dead(file_info):
|
|
return urls
|
|
|
|
expected_filename = file_info.get("filename") or file_info.get("name") or ""
|
|
dead_urls = set()
|
|
mirrors = file_info.get("mirrors") or []
|
|
if not isinstance(mirrors, list):
|
|
mirrors = [mirrors]
|
|
|
|
for mirror in mirrors:
|
|
if not isinstance(mirror, dict):
|
|
continue
|
|
if is_remote_link_marked_dead(mirror):
|
|
dead_url = _normalize_download_url(mirror.get("url"))
|
|
if dead_url:
|
|
dead_urls.add(dead_url)
|
|
continue
|
|
url = _normalize_download_url(mirror.get("url"))
|
|
mirror_filename = mirror.get("filename") or mirror.get("name") or expected_filename
|
|
if (
|
|
url
|
|
and _download_url_looks_like_model_file(url, mirror_filename)
|
|
and url not in urls
|
|
):
|
|
urls.append(url)
|
|
|
|
if check_download_urls_list:
|
|
raw_urls = file_info.get("download_urls") or []
|
|
if not isinstance(raw_urls, list):
|
|
raw_urls = [raw_urls]
|
|
for raw_url in raw_urls:
|
|
url = _normalize_download_url(raw_url)
|
|
if (
|
|
url
|
|
and url not in dead_urls
|
|
and _download_url_looks_like_model_file(url, expected_filename)
|
|
and url not in urls
|
|
):
|
|
urls.append(url)
|
|
|
|
if not is_remote_link_marked_dead(file_info):
|
|
for key in download_url_keys:
|
|
url = _normalize_download_url(file_info.get(key))
|
|
if (
|
|
url
|
|
and url not in dead_urls
|
|
and _download_url_looks_like_model_file(url, expected_filename)
|
|
and url not in urls
|
|
):
|
|
urls.append(url)
|
|
|
|
if prioritize_civitai_last:
|
|
non_civitai_urls = [
|
|
url for url in urls if not url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
]
|
|
civitai_urls = [
|
|
url for url in urls if url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
]
|
|
return non_civitai_urls + civitai_urls
|
|
|
|
return urls
|
|
|
|
|
|
def _normalize_archive_mirrors(file_info: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
mirrors = file_info.get("mirrors") or []
|
|
if not isinstance(mirrors, list):
|
|
mirrors = [mirrors]
|
|
|
|
normalized: List[Dict[str, Any]] = []
|
|
seen_urls = set()
|
|
for mirror in mirrors:
|
|
if not isinstance(mirror, dict):
|
|
continue
|
|
url = _normalize_download_url(mirror.get("url"))
|
|
if not url or url in seen_urls:
|
|
continue
|
|
seen_urls.add(url)
|
|
sha256 = mirror.get("sha256") or mirror.get("hash")
|
|
deleted_at = mirror.get("deletedAt") or mirror.get("deleted_at")
|
|
is_dead = is_remote_link_marked_dead(mirror)
|
|
normalized.append(
|
|
{
|
|
"url": url,
|
|
"source": mirror.get("source") or mirror.get("platform"),
|
|
"filename": mirror.get("filename") or mirror.get("name") or file_info.get("name"),
|
|
"sha256": sha256,
|
|
"hash": sha256,
|
|
"deleted_at": deleted_at,
|
|
"is_dead": is_dead,
|
|
"is_gated": mirror.get("is_gated"),
|
|
"is_paid": mirror.get("is_paid"),
|
|
}
|
|
)
|
|
|
|
download_url = None
|
|
if not is_remote_link_marked_dead(file_info):
|
|
download_url = _normalize_download_url(file_info.get("downloadUrl"))
|
|
if download_url and download_url not in seen_urls:
|
|
normalized.append(
|
|
{
|
|
"url": download_url,
|
|
"source": file_info.get("source") or file_info.get("platform"),
|
|
"filename": file_info.get("name") or file_info.get("filename"),
|
|
"sha256": file_info.get("sha256"),
|
|
"hash": file_info.get("sha256"),
|
|
"deleted_at": file_info.get("deletedAt") or file_info.get("deleted_at"),
|
|
"is_dead": is_remote_link_marked_dead(file_info),
|
|
"is_gated": file_info.get("is_gated"),
|
|
"is_paid": file_info.get("is_paid"),
|
|
}
|
|
)
|
|
|
|
non_civitai = [
|
|
mirror for mirror in normalized
|
|
if not str(mirror.get("url") or "").startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
]
|
|
civitai = [
|
|
mirror for mirror in normalized
|
|
if str(mirror.get("url") or "").startswith(CIVITAI_DOWNLOAD_URL_PREFIXES)
|
|
]
|
|
return non_civitai + civitai
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_archive_file(file_info: Dict[str, Any], model_id: Optional[int], version_id: Optional[int]) -> Dict[str, Any]:
|
|
transformed = _transform_file_entry(file_info)
|
|
download_urls = _collect_download_urls_unified(
|
|
transformed,
|
|
**_ARCHIVE_DOWNLOAD_URL_OPTIONS,
|
|
)
|
|
mirrors = _normalize_archive_mirrors(transformed)
|
|
normalized = normalize_model_file_info(transformed, model_id=model_id, version_id=version_id)
|
|
# Re-extract size using exact logic and append mirror info
|
|
normalized["size"] = extract_file_size(transformed)
|
|
normalized["download_urls"] = download_urls
|
|
normalized["mirrors"] = mirrors
|
|
normalized["mirror_count"] = len(mirrors)
|
|
return normalized
|
|
|
|
|
|
def _normalize_archive_version(
|
|
version: Dict[str, Any],
|
|
context: Dict[str, Any],
|
|
top_files: Optional[List[Dict[str, Any]]] = None,
|
|
) -> Dict[str, Any]:
|
|
model_id = to_int(context.get("id") or version.get("modelId"))
|
|
version_id = to_int(version.get("id"))
|
|
files = _extract_version_files(version, top_files or [])
|
|
normalized_files = [
|
|
_normalize_archive_file(file_info, model_id, version_id)
|
|
for file_info in files
|
|
if isinstance(file_info, dict)
|
|
]
|
|
raw_images = version.get("images") or []
|
|
if not isinstance(raw_images, list):
|
|
raw_images = [raw_images]
|
|
images = [
|
|
normalize_model_image(image)
|
|
for image in raw_images
|
|
if isinstance(image, dict) and (image.get("url") or image.get("imageUrl") or image.get("src"))
|
|
]
|
|
trained_words = extract_trained_words(version.get("trainedWords"), version.get("trigger"))
|
|
return {
|
|
"id": version_id,
|
|
"name": version.get("name") or "",
|
|
"base_model": version.get("baseModel") or version.get("base_model") or version.get("baseModelType"),
|
|
"platform_url": version.get("platform_url") or version.get("platformUrl"),
|
|
"civitai_model_id": version.get("civitai_model_id") or version.get("civitaiModelId"),
|
|
"civitai_model_version_id": version.get("civitai_model_version_id") or version.get("civitaiModelVersionId"),
|
|
"published_at": version.get("publishedAt") or version.get("createdAt"),
|
|
"updated_at": version.get("updatedAt"),
|
|
"description": version.get("description") or "",
|
|
"trained_words": trained_words,
|
|
"stats": version.get("stats") or {},
|
|
"files": normalized_files,
|
|
"images": images,
|
|
"url": f"{CIVARCHIVE_BASE_URL}/models/{model_id}?modelVersionId={version_id}" if model_id and version_id else "",
|
|
}
|
|
|
|
|
|
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,
|
|
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
|
|
fallback_version = {
|
|
"id": resolved.get("version_id"),
|
|
"name": resolved.get("version_name"),
|
|
"baseModel": resolved.get("base_model"),
|
|
"trainedWords": resolved.get("trained_words", []),
|
|
"images": resolved.get("images", []),
|
|
"files": [
|
|
{
|
|
"name": resolved.get("filename"),
|
|
"type": resolved.get("type"),
|
|
"downloadUrl": resolved.get("download_url"),
|
|
"sizeKB": (resolved.get("size") or 0) / 1024 if resolved.get("size") else None,
|
|
"primary": True,
|
|
}
|
|
],
|
|
}
|
|
context = {
|
|
"id": model_id,
|
|
"name": resolved.get("name"),
|
|
"type": resolved.get("type"),
|
|
"tags": resolved.get("tags", []),
|
|
"creator": resolved.get("creator", {}),
|
|
"platform": resolved.get("platform"),
|
|
}
|
|
selected = _normalize_archive_version(fallback_version, context)
|
|
return {
|
|
"source": "civarchive",
|
|
"model_id": model_id,
|
|
"version_id": selected.get("id"),
|
|
"name": context.get("name") or "",
|
|
"type": context.get("type") or "",
|
|
"description": "",
|
|
"tags": context.get("tags") or [],
|
|
"stats": {},
|
|
"creator": context.get("creator") or {},
|
|
"platform": context.get("platform"),
|
|
"url": f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
|
|
"version_url": selected.get("url"),
|
|
"platform_url": selected.get("platform_url"),
|
|
"civitai_model_id": selected.get("civitai_model_id"),
|
|
"civitai_model_version_id": selected.get("civitai_model_version_id"),
|
|
"versions": [selected],
|
|
"selected_version": selected,
|
|
"images": selected.get("images", []),
|
|
}
|
|
if not payload:
|
|
return None
|
|
|
|
data = _normalize_payload(payload)
|
|
model_block = data.get("model") if isinstance(data.get("model"), dict) else data
|
|
context = {k: v for k, v in model_block.items() if k not in {"version", "versions", "modelVersions", "files"}}
|
|
context.setdefault("id", model_id)
|
|
|
|
raw_versions = (
|
|
model_block.get("modelVersions")
|
|
or model_block.get("versions")
|
|
or data.get("modelVersions")
|
|
or data.get("versions")
|
|
or []
|
|
)
|
|
if not isinstance(raw_versions, list):
|
|
raw_versions = [raw_versions]
|
|
if not raw_versions and isinstance(model_block.get("version"), dict):
|
|
raw_versions = [model_block.get("version")]
|
|
|
|
top_files = data.get("files") or model_block.get("files") or []
|
|
if not isinstance(top_files, list):
|
|
top_files = [top_files]
|
|
|
|
active_version = model_block.get("version") if isinstance(model_block.get("version"), dict) else {}
|
|
active_version_id = to_int(active_version.get("id"))
|
|
hydrated_versions: List[Dict[str, Any]] = []
|
|
|
|
for version_summary in raw_versions:
|
|
if not isinstance(version_summary, dict):
|
|
continue
|
|
|
|
current_id = to_int(version_summary.get("id"))
|
|
if current_id and active_version_id and current_id == active_version_id:
|
|
hydrated_versions.append({**version_summary, **active_version})
|
|
else:
|
|
hydrated_versions.append(version_summary)
|
|
|
|
versions = [
|
|
_normalize_archive_version(version, context, top_files)
|
|
for version in hydrated_versions
|
|
if isinstance(version, dict)
|
|
]
|
|
selected = next((version for version in versions if version_id and str(version.get("id")) == str(version_id)), None)
|
|
if not selected and versions:
|
|
selected = versions[0]
|
|
|
|
return {
|
|
"source": "civarchive",
|
|
"model_id": model_id,
|
|
"version_id": selected.get("id") if selected else version_id,
|
|
"name": context.get("name") or context.get("modelName") or "",
|
|
"type": context.get("type") or "",
|
|
"description": context.get("description") or "",
|
|
"tags": context.get("tags") or [],
|
|
"stats": context.get("stats") or {},
|
|
"creator": context.get("creator") or {
|
|
"username": context.get("creator_username") or context.get("username"),
|
|
"name": context.get("creator_name"),
|
|
"url": context.get("creator_url"),
|
|
},
|
|
"platform": context.get("platform") or context.get("platform_name"),
|
|
"url": f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
|
|
"version_url": selected.get("url") if selected else f"{CIVARCHIVE_BASE_URL}/models/{model_id}",
|
|
"platform_url": selected.get("platform_url") if selected else None,
|
|
"civitai_model_id": selected.get("civitai_model_id") if selected else None,
|
|
"civitai_model_version_id": selected.get("civitai_model_version_id") if selected else None,
|
|
"versions": versions,
|
|
"selected_version": selected,
|
|
"images": selected.get("images", []) if selected else [],
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _select_primary_model_file(files: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
valid_files = [
|
|
file_info
|
|
for file_info in files
|
|
if isinstance(file_info, dict)
|
|
and _collect_download_urls_unified(
|
|
file_info,
|
|
**_NORMALIZED_DOWNLOAD_URL_OPTIONS,
|
|
)
|
|
]
|
|
return select_primary_model_file(valid_files)
|
|
|
|
|
|
def _build_result_from_normalized_version(
|
|
model_details: Dict[str, Any],
|
|
version: Dict[str, Any],
|
|
file_info: Dict[str, Any],
|
|
match_type: str,
|
|
) -> Optional[Dict[str, Any]]:
|
|
download_urls = _collect_download_urls_unified(
|
|
file_info,
|
|
**_NORMALIZED_DOWNLOAD_URL_OPTIONS,
|
|
)
|
|
if not download_urls:
|
|
return None
|
|
|
|
model_id = to_int(model_details.get("model_id") or file_info.get("model_id"))
|
|
version_id = to_int(version.get("id") or file_info.get("version_id"))
|
|
filename = file_info.get("name") or file_info.get("filename") or ""
|
|
url = version.get("url") or (
|
|
f"{CIVARCHIVE_BASE_URL}/models/{model_id}?modelVersionId={version_id}"
|
|
if model_id and version_id
|
|
else model_details.get("url", "")
|
|
)
|
|
|
|
tags = model_details.get("tags") or []
|
|
if not isinstance(tags, list):
|
|
tags = [tags] if tags else []
|
|
|
|
size = file_info.get("size")
|
|
if size is None:
|
|
size = _resolve_file_size_bytes(file_info, download_urls)
|
|
|
|
return build_unified_search_result(
|
|
source="civarchive",
|
|
model_id=model_id,
|
|
version_id=version_id,
|
|
name=model_details.get("name") or "",
|
|
version_name=version.get("name") or "",
|
|
type=model_details.get("type") or file_info.get("type"),
|
|
filename=filename,
|
|
url=url,
|
|
platform_url=version.get("platform_url") or version.get("platformUrl"),
|
|
civitai_model_id=version.get("civitai_model_id") or version.get("civitaiModelId"),
|
|
civitai_model_version_id=version.get("civitai_model_version_id") or version.get("civitaiModelVersionId"),
|
|
download_url=download_urls[0],
|
|
download_urls=download_urls,
|
|
size=size,
|
|
base_model=version.get("base_model") or version.get("baseModel") or version.get("baseModelType"),
|
|
tags=tags,
|
|
trained_words=version.get("trained_words") or [],
|
|
images=version.get("images") or [],
|
|
creator=model_details.get("creator") or {},
|
|
platform=model_details.get("platform"),
|
|
is_deleted=False,
|
|
match_type=match_type,
|
|
)
|
|
|
|
|
|
def _hydrate_civarchive_version_with_files(
|
|
model_id: int, version: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
if _select_primary_model_file(version.get("files") or []):
|
|
return version
|
|
|
|
version_id = to_int(version.get("id"))
|
|
if not model_id or not version_id:
|
|
return version
|
|
|
|
details = get_civarchive_model_details(model_id, version_id)
|
|
if not details:
|
|
return version
|
|
|
|
selected = details.get("selected_version")
|
|
if isinstance(selected, dict) and to_int(selected.get("id")) == version_id:
|
|
return selected
|
|
|
|
for candidate in details.get("versions") or []:
|
|
if isinstance(candidate, dict) and to_int(candidate.get("id")) == version_id:
|
|
return candidate
|
|
|
|
return version
|
|
|
|
|
|
def _find_model_title_match_in_model_details(
|
|
model_id: int,
|
|
model_details: Dict[str, Any],
|
|
title_query: str,
|
|
base_model_context: Optional[str] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""For extensionless workflow values, resolve by CivArchive model page title."""
|
|
from ..matcher import match_model_by_title_generic
|
|
|
|
model_name = model_details.get("name", "")
|
|
versions = [
|
|
version
|
|
for version in (model_details.get("versions") or [])
|
|
if isinstance(version, dict)
|
|
]
|
|
selected_version = model_details.get("selected_version")
|
|
if isinstance(selected_version, dict):
|
|
selected_id = to_int(selected_version.get("id"))
|
|
if not any(to_int(version.get("id")) == selected_id for version in versions):
|
|
versions.append(selected_version)
|
|
|
|
def hydrate_version(v):
|
|
return _hydrate_civarchive_version_with_files(model_id, v)
|
|
|
|
def get_base_model(v):
|
|
return v.get("base_model")
|
|
|
|
def select_file(v):
|
|
return _select_primary_model_file(v.get("files") or [])
|
|
|
|
def build_result(v, file_info, confidence):
|
|
return _build_result_from_normalized_version(
|
|
model_details=model_details,
|
|
version=v,
|
|
file_info=file_info,
|
|
match_type="model_title",
|
|
)
|
|
|
|
return match_model_by_title_generic(
|
|
model_id=model_id,
|
|
title_query=title_query,
|
|
model_name=model_name,
|
|
versions=versions,
|
|
base_model_context=base_model_context,
|
|
get_base_model_fn=get_base_model,
|
|
select_file_fn=select_file,
|
|
build_result_fn=build_result,
|
|
hydrate_version_fn=hydrate_version,
|
|
log_prefix="CivArchive",
|
|
)
|
|
|
|
|
|
|
|
|
|
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 = to_int(file_info.get("model_id") or file_info.get("modelId"))
|
|
file_version_id = to_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 != to_int(model_id):
|
|
return False
|
|
if version_id and file_version_id and file_version_id != to_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 is_remote_link_marked_dead(file_info):
|
|
continue
|
|
if not _collect_download_urls_unified(
|
|
file_info,
|
|
**_NORMALIZED_DOWNLOAD_URL_OPTIONS,
|
|
):
|
|
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_download_urls_unified(
|
|
file_info,
|
|
**_NORMALIZED_DOWNLOAD_URL_OPTIONS,
|
|
):
|
|
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
|
|
if is_remote_link_marked_dead(mirror):
|
|
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_download_urls_unified(
|
|
file_info,
|
|
**_NORMALIZED_DOWNLOAD_URL_OPTIONS,
|
|
):
|
|
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 = to_int(model_details.get("model_id") or file_info.get("model_id"))
|
|
version_id = to_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 _build_result_from_payload(
|
|
payload: Dict[str, Any],
|
|
query: str = "",
|
|
preferred_filename: str = "",
|
|
exact_only: bool = False,
|
|
) -> Optional[Dict[str, Any]]:
|
|
context, version, top_files = _extract_model_context(payload)
|
|
if not version:
|
|
return None
|
|
|
|
files = _extract_version_files(version, top_files)
|
|
if not files:
|
|
return None
|
|
|
|
query_value = preferred_filename or query
|
|
selected_file = None
|
|
best_confidence = -1.0
|
|
|
|
for file_info in files:
|
|
file_name = file_info.get("name") or ""
|
|
confidence = calculate_archived_model_confidence(
|
|
query_value,
|
|
context.get("name", ""),
|
|
version.get("name", ""),
|
|
file_name,
|
|
)
|
|
if confidence > best_confidence:
|
|
best_confidence = confidence
|
|
selected_file = file_info
|
|
|
|
if selected_file is None:
|
|
selected_file = _select_primary_file(files)
|
|
best_confidence = calculate_archived_model_confidence(
|
|
query_value,
|
|
context.get("name", ""),
|
|
version.get("name", ""),
|
|
selected_file.get("name", "") if selected_file else "",
|
|
)
|
|
|
|
if selected_file is None:
|
|
return None
|
|
|
|
if exact_only and best_confidence < 100.0:
|
|
return None
|
|
|
|
download_urls = _collect_download_urls_unified(
|
|
selected_file,
|
|
**_ARCHIVE_DOWNLOAD_URL_OPTIONS,
|
|
)
|
|
if not download_urls:
|
|
return None
|
|
|
|
model_id = to_int(
|
|
context.get("id")
|
|
or version.get("modelId")
|
|
or selected_file.get("modelId")
|
|
)
|
|
version_id = to_int(
|
|
version.get("id")
|
|
or selected_file.get("modelVersionId")
|
|
)
|
|
model_name = context.get("name") or selected_file.get("modelName") or ""
|
|
version_name = version.get("name") or ""
|
|
filename = selected_file.get("name") or preferred_filename or query
|
|
match_type = "exact" if best_confidence == 100.0 else "similar"
|
|
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")
|
|
)
|
|
|
|
tags = context.get("tags") or []
|
|
if not isinstance(tags, list):
|
|
tags = [tags] if tags else []
|
|
|
|
trained_words = extract_trained_words(version.get("trainedWords"), version.get("trigger"))
|
|
|
|
civarchive_url = (
|
|
f"{CIVARCHIVE_BASE_URL}/models/{model_id}?modelVersionId={version_id}"
|
|
if model_id and version_id
|
|
else context.get("meta", {}).get("canonical")
|
|
if isinstance(context.get("meta"), dict)
|
|
else ""
|
|
)
|
|
|
|
return build_unified_search_result(
|
|
source="civarchive",
|
|
model_id=model_id,
|
|
version_id=version_id,
|
|
name=model_name,
|
|
version_name=version_name,
|
|
type=context.get("type") or selected_file.get("type"),
|
|
filename=filename,
|
|
url=civarchive_url,
|
|
platform_url=version.get("platform_url") or version.get("platformUrl"),
|
|
civitai_model_id=version.get("civitai_model_id") or version.get("civitaiModelId"),
|
|
civitai_model_version_id=version.get("civitai_model_version_id") or version.get("civitaiModelVersionId"),
|
|
download_url=download_urls[0],
|
|
download_urls=download_urls,
|
|
size=_resolve_file_size_bytes(selected_file, download_urls),
|
|
base_model=version.get("baseModel") or version.get("base_model") or version.get("baseModelType"),
|
|
tags=tags,
|
|
trained_words=trained_words,
|
|
images=version.get("images", []),
|
|
creator={
|
|
"username": context.get("creator_username") or context.get("username"),
|
|
"name": context.get("creator_name"),
|
|
"url": context.get("creator_url"),
|
|
},
|
|
platform=context.get("platform") or context.get("platform_name"),
|
|
is_deleted=bool(context.get("deletedAt") or version.get("deletedAt")),
|
|
match_type=match_type,
|
|
confidence=best_confidence,
|
|
sha256=sha256,
|
|
hash=sha256,
|
|
hashes=hashes,
|
|
)
|
|
|
|
|
|
def resolve_civarchive_by_hash(
|
|
sha256: str,
|
|
query: str = "",
|
|
exact_only: bool = False,
|
|
model_type: Optional[str] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Resolve a model by SHA256 hash through CivArchive."""
|
|
if not sha256:
|
|
return None
|
|
|
|
result = _resolve_hash_search_candidate_from_page(
|
|
{"url": f"/sha256/{sha256.lower()}", "name": query or sha256},
|
|
sha256,
|
|
query=query or sha256,
|
|
exact_only=exact_only,
|
|
expected_model_type=model_type,
|
|
)
|
|
if result:
|
|
return result
|
|
|
|
payload = _request_json(f"/sha256/{sha256.lower()}")
|
|
if not payload:
|
|
return None
|
|
|
|
return _build_result_from_payload(
|
|
payload,
|
|
query=query or sha256,
|
|
exact_only=exact_only,
|
|
)
|
|
|
|
|
|
def resolve_civarchive_model_version(
|
|
model_id: int,
|
|
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
|
|
|
|
payload = _request_model_payload(model_id, version_id, prefer_page=prefer_page)
|
|
if not payload:
|
|
return None
|
|
|
|
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]:
|
|
parsed = parse_civarchive_url(candidate.get("url", ""))
|
|
if parsed:
|
|
return (
|
|
parsed.get("sha256"),
|
|
parsed.get("model_id"),
|
|
parsed.get("version_id"),
|
|
)
|
|
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 = get_filename_from_path(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 build_unified_search_result(
|
|
source="civarchive",
|
|
model_id=candidate.get("model_id") or candidate.get("modelId"),
|
|
version_id=candidate.get("version_id") or candidate.get("modelVersionId"),
|
|
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=_resolve_file_size_bytes(candidate, download_urls),
|
|
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,
|
|
expected_model_type: Optional[str] = None,
|
|
) -> 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 = _prefer_model_links_for_expected_type(
|
|
_extract_model_links_from_html(page_text or ""),
|
|
expected_model_type=expected_model_type,
|
|
)
|
|
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,
|
|
exact_only: bool = False,
|
|
base_model_context: Optional[str] = None,
|
|
allow_model_title_match: bool = False,
|
|
model_type: Optional[str] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
parsed = parse_civarchive_url(candidate.get("url", ""))
|
|
if not parsed:
|
|
return None
|
|
|
|
if parsed.get("sha256"):
|
|
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,
|
|
expected_model_type=model_type,
|
|
)
|
|
elif parsed.get("model_id"):
|
|
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
|
|
|
|
if result and not result.get("confidence"):
|
|
result["confidence"] = calculate_archived_model_confidence(
|
|
query,
|
|
candidate.get("name", ""),
|
|
"",
|
|
result.get("filename", ""),
|
|
)
|
|
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 search_civarchive(
|
|
query: str,
|
|
model_type: Optional[str] = None,
|
|
limit: int = DEFAULT_CIVARCHIVE_CANDIDATE_LIMIT,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search CivArchive by model name or filename.
|
|
"""
|
|
normalized_query = (query or "").strip()
|
|
if not normalized_query:
|
|
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])
|
|
|
|
results = []
|
|
seen = set()
|
|
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
|
|
seen.add(identity)
|
|
|
|
resolved = _resolve_search_candidate(
|
|
candidate,
|
|
normalized_query,
|
|
model_type=model_type,
|
|
)
|
|
if not resolved:
|
|
continue
|
|
|
|
if resolved.get("confidence", 0) < 40:
|
|
continue
|
|
results.append(resolved)
|
|
if len(results) >= detail_limit:
|
|
break
|
|
|
|
results.sort(
|
|
key=lambda item: (
|
|
item.get("confidence", 0),
|
|
1 if item.get("match_type") == "exact" else 0,
|
|
),
|
|
reverse=True,
|
|
)
|
|
_search_cache[cache_key] = list(results)
|
|
return results
|
|
|
|
|
|
def search_civarchive_for_file(
|
|
filename: str,
|
|
model_type: Optional[str] = None,
|
|
base_model_context: Optional[str] = None,
|
|
exact_only: bool = False,
|
|
limit: int = DEFAULT_CIVARCHIVE_CANDIDATE_LIMIT,
|
|
progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Search CivArchive for the best downloadable match for a model filename.
|
|
"""
|
|
normalized_filename = (filename or "").strip()
|
|
if not normalized_filename:
|
|
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}"
|
|
)
|
|
if cache_key in _search_cache:
|
|
_report_progress(
|
|
progress_callback,
|
|
"cache",
|
|
"Using CivArchive cache",
|
|
86,
|
|
)
|
|
return _search_cache[cache_key]
|
|
|
|
sha256 = _extract_sha256(normalized_filename)
|
|
if sha256:
|
|
_report_progress(
|
|
progress_callback,
|
|
"hash",
|
|
"Resolving CivArchive hash",
|
|
40,
|
|
sha256=sha256,
|
|
)
|
|
result = resolve_civarchive_by_hash(
|
|
sha256,
|
|
query=normalized_filename,
|
|
exact_only=exact_only,
|
|
model_type=model_type,
|
|
)
|
|
_search_cache[cache_key] = result
|
|
_report_progress(
|
|
progress_callback,
|
|
"done",
|
|
"CivArchive checked",
|
|
92,
|
|
found=bool(result),
|
|
)
|
|
return result
|
|
|
|
best_match = None
|
|
best_confidence = 0.0
|
|
best_rank = -9999.0
|
|
seen = set()
|
|
allow_model_title_match = not exact_only and not _has_known_model_extension(
|
|
get_filename_from_path(normalized_filename)
|
|
)
|
|
|
|
try:
|
|
search_queries = build_filename_search_queries(normalized_filename)
|
|
query_count = max(1, len(search_queries))
|
|
for query_index, search_query in enumerate(search_queries, start=1):
|
|
query_percent = 30 + ((query_index - 1) / query_count) * 18
|
|
_report_progress(
|
|
progress_callback,
|
|
"query",
|
|
f"CivArchive query {query_index}/{query_count}",
|
|
query_percent,
|
|
query=search_query,
|
|
query_index=query_index,
|
|
query_count=query_count,
|
|
)
|
|
candidates = _search_page(search_query, model_type=model_type)
|
|
_report_progress(
|
|
progress_callback,
|
|
"candidates",
|
|
f"CivArchive candidates: {len(candidates)}",
|
|
min(55, query_percent + 10),
|
|
candidate_count=len(candidates),
|
|
checked_candidate_count=len(seen),
|
|
)
|
|
# 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, 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}/{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=detail_limit,
|
|
)
|
|
seen.add(identity)
|
|
|
|
resolved = _resolve_search_candidate(
|
|
candidate,
|
|
normalized_filename,
|
|
exact_only=exact_only,
|
|
base_model_context=base_model_context,
|
|
allow_model_title_match=allow_model_title_match,
|
|
model_type=model_type,
|
|
)
|
|
if not resolved:
|
|
continue
|
|
|
|
if resolved.get("match_type") == "model_title":
|
|
confidence = float(
|
|
resolved.get("title_confidence")
|
|
or resolved.get("confidence")
|
|
or calculate_model_title_confidence(
|
|
normalized_filename,
|
|
resolved.get("name", ""),
|
|
)
|
|
)
|
|
else:
|
|
confidence = calculate_archived_model_confidence(
|
|
normalized_filename,
|
|
resolved.get("name", ""),
|
|
resolved.get("version_name", ""),
|
|
resolved.get("filename", ""),
|
|
)
|
|
if exact_only and confidence < 100.0:
|
|
continue
|
|
|
|
resolved["confidence"] = confidence
|
|
resolved["match_type"] = "exact" if confidence == 100.0 else "similar"
|
|
|
|
should_update, rank, base_model_matches = should_update_best_match(
|
|
confidence, resolved.get("base_model"), base_model_context, best_rank, exact_only
|
|
)
|
|
if exact_only and confidence < 100.0:
|
|
continue
|
|
|
|
if should_update:
|
|
best_rank = rank
|
|
best_confidence = confidence
|
|
best_match = resolved
|
|
|
|
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,
|
|
"found",
|
|
"Found CivArchive match",
|
|
92,
|
|
confidence=confidence,
|
|
checked_candidate_count=len(seen),
|
|
)
|
|
return best_match
|
|
|
|
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)
|
|
):
|
|
break
|
|
except CivArchiveSearchError:
|
|
raise
|
|
except Exception as e:
|
|
log.exception(f"CivArchive search error for {normalized_filename}: {e}")
|
|
_report_progress(
|
|
progress_callback,
|
|
"error",
|
|
f"CivArchive search error: {e}",
|
|
100,
|
|
status="error",
|
|
)
|
|
return None
|
|
|
|
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)
|
|
and not _is_hash_verified_exact_match(best_match, best_confidence)
|
|
):
|
|
best_match = None
|
|
|
|
_search_cache[cache_key] = best_match
|
|
_report_progress(
|
|
progress_callback,
|
|
"found" if best_match else "done",
|
|
"Found CivArchive match" if best_match else "CivArchive checked",
|
|
92,
|
|
found=bool(best_match),
|
|
confidence=best_confidence if best_match else None,
|
|
checked_candidate_count=len(seen),
|
|
candidate_limit=detail_limit,
|
|
)
|
|
return best_match
|
|
|
|
|
|
def build_civarchive_custom_result(
|
|
details: Dict[str, Any],
|
|
expected_filename: str = "",
|
|
) -> Optional[Dict[str, Any]]:
|
|
if not isinstance(details, dict):
|
|
return None
|
|
|
|
selected_version = (
|
|
details.get("selected_version")
|
|
if isinstance(details.get("selected_version"), dict)
|
|
else {}
|
|
)
|
|
file_info = select_primary_model_file(
|
|
selected_version.get("files") or [],
|
|
expected_filename, require_download=True
|
|
) or {}
|
|
download_urls = file_info.get("download_urls") or []
|
|
if not isinstance(download_urls, list):
|
|
download_urls = [download_urls]
|
|
download_url = (
|
|
file_info.get("download_url")
|
|
or file_info.get("downloadUrl")
|
|
or (download_urls[0] if download_urls else "")
|
|
)
|
|
if not download_url:
|
|
return None
|
|
|
|
filename = (
|
|
file_info.get("name")
|
|
or file_info.get("filename")
|
|
or get_filename_from_path(expected_filename)
|
|
or details.get("name")
|
|
or "model"
|
|
)
|
|
hashes = (
|
|
file_info.get("hashes")
|
|
if isinstance(file_info.get("hashes"), dict)
|
|
else {}
|
|
)
|
|
sha256 = (
|
|
file_info.get("sha256")
|
|
or file_info.get("hash")
|
|
or hashes.get("SHA256")
|
|
or hashes.get("sha256")
|
|
)
|
|
version_id = details.get("version_id") or selected_version.get("id")
|
|
model_id = details.get("model_id")
|
|
version_url = (
|
|
details.get("version_url")
|
|
or selected_version.get("url")
|
|
or (
|
|
f"https://civarchive.com/models/{model_id}?modelVersionId={version_id}"
|
|
if model_id and version_id
|
|
else details.get("url")
|
|
)
|
|
)
|
|
return build_custom_url_result(
|
|
source="civarchive",
|
|
model_id=model_id,
|
|
version_id=version_id,
|
|
name=details.get("name") or filename,
|
|
version_name=selected_version.get("name") or "",
|
|
type=details.get("type") or file_info.get("type") or "",
|
|
filename=filename,
|
|
url=version_url or details.get("url"),
|
|
download_url=download_url,
|
|
size=file_info.get("size"),
|
|
base_model=selected_version.get("base_model"),
|
|
tags=details.get("tags") or [],
|
|
trained_words=selected_version.get("trained_words") or [],
|
|
images=details.get("images") or selected_version.get("images") or [],
|
|
description=selected_version.get("description")
|
|
or details.get("description")
|
|
or "",
|
|
sha256=sha256,
|
|
hashes=hashes,
|
|
platform_url=details.get("platform_url")
|
|
or selected_version.get("platform_url"),
|
|
download_urls=[url for url in download_urls if url],
|
|
hash=sha256,
|
|
platform=details.get("platform"),
|
|
)
|
|
|