Files

1427 lines
59 KiB
Python

from __future__ import annotations
import asyncio
import inspect
import json
import os
import re
import shutil
import subprocess
import tempfile
import time
import uuid
from difflib import SequenceMatcher
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlparse
import aiohttp
from aiohttp import web
import folder_paths
from server import PromptServer
from .model_finder import MODEL_EXTENSIONS, analyze, basename, normalize_path, normalized_stem
from .node_installer import (
COMFY_MANAGER_NODE_MAP_URL,
allowed_repo_url,
github_fallback_candidates,
install_plugin,
missing_workflow_node_packages,
missing_workflow_node_types,
)
SOURCE_TIMEOUT = aiohttp.ClientTimeout(total=120, connect=20, sock_read=30)
DOWNLOAD_TIMEOUT = aiohttp.ClientTimeout(total=None, connect=30, sock_read=600)
ALLOWED_DOWNLOAD_HOSTS = {
"civitai.com",
"huggingface.co",
"cdn-lfs.hf.co",
"cdn-lfs-us-1.hf.co",
"cdn-lfs-eu-1.hf.co",
"cas-bridge.xethub.hf.co",
}
QUARK_MODEL_LIBRARIES = (
{"name": "夸克模型库 1", "share_id": "fb913d649b18", "url": "https://pan.quark.cn/s/fb913d649b18"},
{"name": "夸克模型库 2", "share_id": "4680ac866516", "url": "https://pan.quark.cn/s/4680ac866516"},
)
QUARK_API = "https://drive-pc.quark.cn/1/clouddrive"
QUARK_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 "
"Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch"
)
QUARK_CATEGORY_FOLDERS = {
"LLM": {"LLM", "llm"},
"instantid": {"instantid"},
"ipadapter": {"ipadapter", "ip_adapter"},
"sams": {"sams"},
"ultralytics_bbox": {"ultralytics_bbox", "bbox"},
"ultralytics_segm": {"ultralytics_segm", "segm"},
"checkpoints": {"checkpoints"},
"loras": {"loras", "lora"},
"vae": {"vae"},
"controlnet": {"controlnet"},
"clip_vision": {"clip_vision"},
"text_encoders": {"text_encoders", "clip"},
"diffusion_models": {"diffusion_models", "unet"},
"upscale_models": {"upscale_models"},
"embeddings": {"embeddings"},
}
DOWNLOAD_CATEGORY_ALIASES = {
"LLM": ("LLM",),
"instantid": ("instantid",),
"ipadapter": ("ipadapter",),
"sams": ("sams",),
"ultralytics_bbox": ("ultralytics_bbox",),
"ultralytics_segm": ("ultralytics_segm",),
"checkpoints": ("checkpoints",),
"loras": ("loras",),
"vae": ("vae",),
"controlnet": ("controlnet",),
"clip_vision": ("clip_vision",),
"text_encoders": ("text_encoders", "clip"),
"diffusion_models": ("diffusion_models", "unet"),
"upscale_models": ("upscale_models",),
"embeddings": ("embeddings",),
"detection": ("detection",),
"frame_interpolation": ("frame_interpolation",),
"audio_encoders": ("audio_encoders",),
"background_removal": ("background_removal",),
"geometry_estimation": ("geometry_estimation",),
"optical_flow": ("optical_flow",),
}
KNOWN_MODEL_SOURCES = {
"qwen_3_06b_base.safetensors": {
"provider": "Hugging Face · circlestone-labs/Anima",
"name": "qwen_3_06b_base.safetensors",
"url": "https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/text_encoders/qwen_3_06b_base.safetensors?download=true",
"size": 1192135096,
"confidence": 1.0,
},
"anima-base-v1.0.safetensors": {
"provider": "Hugging Face · circlestone-labs/Anima",
"name": "anima-base-v1.0.safetensors",
"url": "https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/diffusion_models/anima-base-v1.0.safetensors?download=true",
"size": 4182218328,
"confidence": 1.0,
},
"fantasytalking_fp16.safetensors": {
"provider": "Hugging Face · Kijai/WanVideo_comfy",
"name": "fantasytalking_fp16.safetensors",
"url": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/fantasytalking_fp16.safetensors",
"size": 1684038568,
"confidence": 1.0,
},
}
DOWNLOAD_JOBS: dict[str, dict[str, Any]] = {}
DOWNLOAD_TASKS: dict[str, asyncio.Task[Any]] = {}
EXTERNAL_FOLDER_CONFIG = Path(__file__).with_name("external_model_folder.json")
QUARK_AUTH_CONFIG = Path(__file__).with_name("quark_auth.json")
EXTERNAL_INDEX_CACHE: dict[str, list[dict[str, Any]]] = {}
EXTERNAL_INDEX_ROOT: str | None = None
EXTERNAL_INDEX_TASK: asyncio.Task[Any] | None = None
def _safe_query(name: str) -> str:
return re.sub(r"[_\-.]+", " ", basename(name).rsplit(".", 1)[0]).strip()
def _exact_model_name(wanted: str, candidate: str) -> bool:
return basename(wanted).lower() == basename(candidate).lower()
def _is_https_url(value: Any) -> bool:
return isinstance(value, str) and value.startswith("https://")
def _allowed_download_url(value: Any) -> bool:
if not _is_https_url(value):
return False
host = (urlparse(value).hostname or "").lower()
return (
host in ALLOWED_DOWNLOAD_HOSTS
or host.endswith(".civitai.com")
or host.endswith(".hf.co")
or host.endswith(".xethub.hf.co")
)
def _allowed_quark_download_url(value: Any) -> bool:
if not _is_https_url(value):
return False
host = (urlparse(value).hostname or "").lower()
return host.endswith((".quark.cn", ".uc.cn", ".alicdn.com", ".aliyuncs.com"))
def _load_quark_cookie() -> str:
try:
data = json.loads(QUARK_AUTH_CONFIG.read_text(encoding="utf-8"))
return str(data.get("cookie", "")).strip()
except (OSError, ValueError, TypeError, AttributeError):
return ""
def _quark_token(value: Any) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
return next((_quark_token(item) for item in value if _quark_token(item)), "")
if isinstance(value, dict):
for key in ("share_fid_token", "fid_token", "token"):
token = _quark_token(value.get(key))
if token:
return token
return ""
def _save_quark_cookie(cookie: str) -> None:
value = str(cookie or "").strip()
if value:
QUARK_AUTH_CONFIG.write_text(json.dumps({"cookie": value}, ensure_ascii=False), encoding="utf-8")
elif QUARK_AUTH_CONFIG.exists():
QUARK_AUTH_CONFIG.unlink()
def _safe_filename(value: str) -> str:
name = basename(value).strip()
if not name or not name.lower().endswith(tuple(MODEL_EXTENSIONS)):
raise web.HTTPBadRequest(text="Invalid model filename")
return name
def _official_relative_model_name(value: str, category: str) -> Path:
normalized = normalize_path(value)
if category == "ultralytics_bbox" and normalized.lower().startswith("bbox/"):
normalized = normalized.split("/", 1)[1]
elif category == "ultralytics_segm" and normalized.lower().startswith("segm/"):
normalized = normalized.split("/", 1)[1]
return Path(_safe_filename(normalized))
def _size_value(value: Any, multiplier: int = 1) -> int | None:
try:
size = int(float(value) * multiplier)
return size if size > 0 else None
except (TypeError, ValueError, OverflowError):
return None
def _content_range_total(value: Any) -> int | None:
match = re.search(r"/(\d+)$", str(value or ""))
return _size_value(match.group(1)) if match else None
def _is_model_payload(path: Path) -> bool:
if path.stat().st_size < 1024:
return False
with path.open("rb") as file:
prefix = file.read(256).lower()
return b"git-lfs.github.com/spec" not in prefix and not prefix.lstrip().startswith((b"<html", b"<!doctype", b"{\"error"))
def _target_directory(category: str) -> Path:
aliases = DOWNLOAD_CATEGORY_ALIASES.get(category, (category,))
registered = getattr(folder_paths, "folder_names_and_paths", None)
if category not in DOWNLOAD_CATEGORY_ALIASES and isinstance(registered, dict) and category not in registered:
raise web.HTTPBadRequest(text=f"No registered model folder for category: {category}")
for candidate in aliases:
try:
paths = folder_paths.get_folder_paths(candidate)
except Exception:
continue
if paths:
resolved = [Path(path).resolve() for path in paths]
target = next(
(path for path in resolved if path.name.lower() == category.lower()),
next((path for path in resolved if path.name.lower() == candidate.lower()), resolved[0]),
)
target.mkdir(parents=True, exist_ok=True)
return target
raise web.HTTPBadRequest(text=f"No configured model folder for category: {category}")
def _registered_model_extensions() -> set[str]:
extensions = {extension.lower() for extension in MODEL_EXTENSIONS}
registered = getattr(folder_paths, "folder_names_and_paths", None)
if not isinstance(registered, dict):
return extensions
for _, value in registered.items():
if not isinstance(value, (tuple, list)) or len(value) < 2:
continue
accepted = value[1]
if not isinstance(accepted, (set, tuple, list)):
continue
extensions.update(
str(extension).lower()
for extension in accepted
if isinstance(extension, str) and extension.startswith(".")
)
return extensions
def _registered_category_roots() -> list[tuple[str, Path]]:
roots: list[tuple[str, Path]] = []
registered = getattr(folder_paths, "folder_names_and_paths", None)
if not isinstance(registered, dict):
return roots
for category, value in registered.items():
if not isinstance(value, (tuple, list)) or not value:
continue
paths = value[0]
if not isinstance(paths, (tuple, list)):
continue
for path in paths:
try:
roots.append((str(category), Path(path).resolve()))
except (OSError, TypeError, ValueError):
continue
return roots
def _registered_category(value: Any) -> str | None:
name = str(value or "").strip()
registered = getattr(folder_paths, "folder_names_and_paths", None)
if not name or not isinstance(registered, dict):
return None
return next((str(category) for category in registered if str(category).lower() == name.lower()), None)
def _node_registered_category(node_type: Any, widget_name: Any, widget_value: Any = None) -> str | None:
"""Resolve a model category from the installed node's actual INPUT_TYPES registration."""
try:
import nodes
node_class = nodes.NODE_CLASS_MAPPINGS.get(str(node_type or ""))
input_types = node_class.INPUT_TYPES()
except Exception:
return None
widget = str(widget_name or "")
sections = tuple(
section
for section in (input_types.get("required", {}), input_types.get("optional", {}))
if isinstance(section, dict)
)
spec = next((section.get(widget) for section in sections if widget in section), None)
if spec is None and isinstance(widget_value, str):
normalized_value = normalize_path(widget_value).lower()
spec = next(
(
candidate
for section in sections
for candidate in section.values()
if isinstance(candidate, (tuple, list))
and candidate
and isinstance(candidate[0], (tuple, list))
and normalized_value in {
normalize_path(str(item)).lower()
for item in candidate[0]
if isinstance(item, str)
}
),
None,
)
values = spec[0] if isinstance(spec, (tuple, list)) and spec else None
if isinstance(values, (tuple, list)):
normalized = {normalize_path(str(item)).lower() for item in values if isinstance(item, str)}
if normalized:
matches = []
registered = getattr(folder_paths, "folder_names_and_paths", {})
for category in registered if isinstance(registered, dict) else ():
try:
available = {
normalize_path(str(item)).lower()
for item in folder_paths.get_filename_list(category)
}
except Exception:
continue
if normalized == available or normalized.intersection(available):
matches.append(str(category))
if len(set(matches)) == 1:
return matches[0]
try:
source = inspect.getsource(node_class.INPUT_TYPES)
except (OSError, TypeError):
return None
categories = {
category
for raw in re.findall(r"get_filename_list\(\s*['\"]([^'\"]+)['\"]", source)
if (category := _registered_category(raw))
}
return next(iter(categories)) if len(categories) == 1 else None
def _annotate_registered_widget_categories(payload: Any) -> Any:
"""Attach actual ComfyUI model folder categories before model_finder analyzes widgets."""
if not isinstance(payload, dict):
return payload
for node in payload.get("nodes", []):
if not isinstance(node, dict):
continue
node_type = node.get("type") or node.get("class_type")
for widget in node.get("widgets", []):
if not isinstance(widget, dict) or widget.get("directory"):
continue
category = _node_registered_category(node_type, widget.get("name"), widget.get("value"))
if category:
widget["directory"] = category
return payload
def _resolve_model_category(model: dict[str, Any], candidates: list[dict[str, Any]]) -> str:
current_value = str(model.get("category") or "unknown")
node_category = _node_registered_category(model.get("node_type"), model.get("widget"), model.get("name"))
if node_category:
return node_category
current = _registered_category(current_value)
if current or current_value in DOWNLOAD_CATEGORY_ALIASES:
return current or current_value
match_category = _registered_category((model.get("match") or {}).get("category"))
if match_category:
return match_category
candidate_categories = {
category
for candidate in candidates
if (category := _registered_category(candidate.get("category_hint")))
}
return next(iter(candidate_categories)) if len(candidate_categories) == 1 else "unknown"
def _external_category_hint(path: Path, root: Path) -> str | None:
official_names: dict[str, str] = {
category.lower(): category for category in DOWNLOAD_CATEGORY_ALIASES
}
registered = getattr(folder_paths, "folder_names_and_paths", None)
if isinstance(registered, dict):
official_names.update({str(category).lower(): str(category) for category in registered})
for category, official_root in _registered_category_roots():
official_names.setdefault(official_root.name.lower(), category)
try:
parts = path.resolve().relative_to(root.resolve()).parts[:-1]
except (OSError, ValueError):
return None
for part in reversed(parts):
category = official_names.get(part.lower())
if category:
return category
return None
def _clear_filename_cache(category: str) -> None:
cache = getattr(folder_paths, "filename_list_cache", None)
if isinstance(cache, dict):
for candidate in DOWNLOAD_CATEGORY_ALIASES.get(category, (category,)):
cache.pop(candidate, None)
def _load_external_folder() -> Path | None:
try:
data = json.loads(EXTERNAL_FOLDER_CONFIG.read_text(encoding="utf-8"))
path = Path(str(data.get("path", ""))).expanduser().resolve()
return path if path.is_dir() else None
except (OSError, ValueError, TypeError):
return None
def _save_external_folder(path: Path) -> None:
global EXTERNAL_INDEX_CACHE, EXTERNAL_INDEX_ROOT
EXTERNAL_FOLDER_CONFIG.write_text(
json.dumps({"path": str(path)}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
EXTERNAL_INDEX_CACHE = {}
EXTERNAL_INDEX_ROOT = None
def _choose_external_folder() -> Path | None:
if os.name != "nt":
raise RuntimeError("Native folder selection is currently supported on Windows only")
script = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$owner = New-Object System.Windows.Forms.Form; "
"$owner.TopMost = $true; "
"$owner.ShowInTaskbar = $false; "
"$owner.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen; "
"$owner.Size = New-Object System.Drawing.Size(1, 1); "
"$owner.Opacity = 0; "
"$owner.Show(); "
"$owner.Activate(); "
"$owner.BringToFront(); "
"$dialog = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$dialog.Description = '选择外部模型库文件夹'; "
"$dialog.ShowNewFolderButton = $true; "
"try { "
"if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) "
"{ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $dialog.SelectedPath } "
"} finally { $dialog.Dispose(); $owner.Close(); $owner.Dispose(); }"
)
completed = subprocess.run(
["powershell.exe", "-NoProfile", "-STA", "-Command", script],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=300,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
selected = completed.stdout.strip().lstrip("\ufeff")
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or "Unable to open the folder selector")
if not selected:
return None
path = Path(selected).resolve()
if not path.is_dir():
raise RuntimeError("Selected external model folder does not exist")
return path
def _external_model_index(root: Path | None) -> dict[str, list[dict[str, Any]]]:
index: dict[str, list[dict[str, Any]]] = {}
if root is None or not root.is_dir():
return index
extensions = _registered_model_extensions()
for directory, _, files in os.walk(root, onerror=lambda _: None):
for filename in files:
if Path(filename).suffix.lower() not in extensions:
continue
path = (Path(directory) / filename).resolve()
try:
size = path.stat().st_size
except OSError:
continue
index.setdefault(filename.lower(), []).append(
{
"name": filename,
"path": str(path),
"size": size,
"category_hint": _external_category_hint(path, root),
}
)
return index
def _external_model_index_for_names(
root: Path | None,
wanted_names: set[str],
category_hints: dict[str, str] | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""Quickly find only the exact model filenames required by the current workflow."""
index: dict[str, list[dict[str, Any]]] = {}
wanted = {basename(name).lower() for name in wanted_names if basename(name)}
if root is None or not root.is_dir() or not wanted:
return index
extensions = _registered_model_extensions()
remaining = set(wanted)
def scan(search_root: Path, names: set[str]) -> None:
for directory, _, files in os.walk(search_root, onerror=lambda _: None):
for filename in files:
key = filename.lower()
if key not in names or Path(filename).suffix.lower() not in extensions:
continue
path = (Path(directory) / filename).resolve()
try:
size = path.stat().st_size
except OSError:
continue
index.setdefault(key, []).append(
{
"name": filename,
"path": str(path),
"size": size,
"category_hint": _external_category_hint(path, root),
}
)
normalized_hints = {
basename(name).lower(): str(category)
for name, category in (category_hints or {}).items()
if basename(name) and category in DOWNLOAD_CATEGORY_ALIASES
}
for category in dict.fromkeys(normalized_hints.get(name) for name in wanted):
if not category:
continue
category_names = {name for name in remaining if normalized_hints.get(name) == category}
category_roots = []
for alias in DOWNLOAD_CATEGORY_ALIASES[category]:
category_roots.extend((root / alias, root / "models" / alias, root / "ComfyUI" / "models" / alias))
for category_root in dict.fromkeys(category_roots):
if category_root.is_dir() and category_names:
scan(category_root, category_names)
category_names.difference_update(index)
remaining.difference_update(index)
if not remaining:
return index
if remaining:
scan(root, remaining)
return index
async def _refresh_external_index(root: Path) -> None:
global EXTERNAL_INDEX_CACHE, EXTERNAL_INDEX_ROOT, EXTERNAL_INDEX_TASK
try:
EXTERNAL_INDEX_CACHE = await asyncio.to_thread(_external_model_index, root)
EXTERNAL_INDEX_ROOT = str(root)
finally:
EXTERNAL_INDEX_TASK = None
async def _external_candidates_index(
root: Path | None,
wanted_names: set[str],
category_hints: dict[str, str] | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""Return exact external matches immediately, while refreshing the complete cache in the background."""
global EXTERNAL_INDEX_TASK
if root is None:
return {}
if EXTERNAL_INDEX_ROOT == str(root):
return EXTERNAL_INDEX_CACHE
exact_index = await asyncio.to_thread(_external_model_index_for_names, root, wanted_names, category_hints)
if EXTERNAL_INDEX_TASK is None:
EXTERNAL_INDEX_TASK = asyncio.create_task(_refresh_external_index(root))
return exact_index
def _move_external_model(source_value: str, wanted_name: str, category: str, root: Path | None) -> dict[str, Any]:
global EXTERNAL_INDEX_CACHE, EXTERNAL_INDEX_ROOT
if root is None or not root.is_dir():
raise web.HTTPBadRequest(text="External model folder is not configured")
source = Path(source_value).expanduser().resolve()
try:
source.relative_to(root)
except ValueError as error:
raise web.HTTPBadRequest(text="Source file is outside the configured external model folder") from error
if not source.is_file() or basename(wanted_name).casefold() != source.name.casefold():
raise web.HTTPBadRequest(text="External model file no longer matches the missing model")
if not _is_model_payload(source):
raise web.HTTPBadRequest(text="External file is not a valid model payload")
target_dir = _target_directory(category)
relative_name = _official_relative_model_name(source.name, category)
target = (target_dir / relative_name).resolve()
try:
target.relative_to(target_dir)
except ValueError as error:
raise web.HTTPBadRequest(text="Invalid target model path") from error
if target.exists():
raise web.HTTPConflict(text=f"Model already exists: {target}")
target.parent.mkdir(parents=True, exist_ok=True)
size = source.stat().st_size
shutil.move(str(source), str(target))
EXTERNAL_INDEX_CACHE = {}
EXTERNAL_INDEX_ROOT = None
_clear_filename_cache(category)
return {
"moved": True,
"filename": target.name,
"relative_name": relative_name.as_posix(),
"category": category,
"path": str(target),
"size": size,
}
async def _get_json(session: aiohttp.ClientSession, url: str) -> Any:
try:
async with session.get(url, headers={"User-Agent": "ComfyUI_FindModels/1.0"}) as response:
if response.status == 200:
return await response.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
pass
return None
async def _remote_size(session: aiohttp.ClientSession, candidate: dict[str, Any]) -> None:
url = candidate.get("url")
if candidate.get("size") or not _allowed_download_url(url):
return
try:
async with session.head(url, allow_redirects=True, headers={"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.4.0"}) as response:
size = _size_value(
response.headers.get("X-Linked-Size")
or response.headers.get("X-File-Size")
or response.headers.get("Content-Length")
)
candidate["size"] = size if size and size >= 1024 else None
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
pass
async def _validate_web_candidate(session: aiohttp.ClientSession, candidate: dict[str, Any]) -> dict[str, Any] | None:
url = candidate.get("url")
if not _allowed_download_url(url):
return None
try:
async with session.head(
url,
allow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.5.0"},
) as response:
if response.status != 200 or not _allowed_download_url(str(response.url)):
return None
content_type = response.headers.get("Content-Type", "").lower()
if "text/html" in content_type or "application/json" in content_type:
return None
await _remote_size(session, candidate)
return candidate
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
return None
async def _civitai_candidates(session: aiohttp.ClientSession, name: str) -> list[dict[str, Any]]:
query = quote(_safe_query(name))
data = await _get_json(session, f"https://civitai.com/api/v1/models?query={query}&limit=4")
candidates = []
for model in (data or {}).get("items", []):
for version in model.get("modelVersions", [])[:2]:
for file in version.get("files", []):
file_name = file.get("name", "")
download_url = file.get("downloadUrl")
if _is_https_url(download_url) and file_name:
candidates.append(
{
"provider": "Civitai",
"model": model.get("name"),
"name": file_name,
"url": download_url,
"size": _size_value(file.get("sizeKB"), 1024),
"confidence": round(
SequenceMatcher(None, normalized_stem(name), normalized_stem(file_name)).ratio(),
3,
),
}
)
return candidates
async def _huggingface_candidates(session: aiohttp.ClientSession, name: str) -> list[dict[str, Any]]:
query = quote(_safe_query(name))
models = await _get_json(session, f"https://huggingface.co/api/models?search={query}&limit=4&full=true")
candidates = []
for model in models or []:
repo_id = model.get("id")
if not repo_id:
continue
for sibling in model.get("siblings", []):
file_name = sibling.get("rfilename", "")
if not file_name or "." + file_name.rsplit(".", 1)[-1].lower() not in MODEL_EXTENSIONS:
continue
confidence = SequenceMatcher(None, normalized_stem(name), normalized_stem(file_name)).ratio()
if confidence >= 0.45:
candidates.append(
{
"provider": "Hugging Face",
"model": repo_id,
"name": basename(file_name),
"url": f"https://huggingface.co/{repo_id}/resolve/main/{quote(file_name)}",
"size": _size_value(sibling.get("size") or (sibling.get("lfs") or {}).get("size")),
"confidence": round(confidence, 3),
}
)
return candidates
async def _quark_json(
session: aiohttp.ClientSession, method: str, path: str, *, share_id: str, data: Any = None
) -> Any:
headers = {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json;charset=UTF-8",
"Origin": "https://pan.quark.cn",
"Referer": f"https://pan.quark.cn/s/{share_id}",
"User-Agent": QUARK_USER_AGENT,
}
cookie = _load_quark_cookie()
if cookie:
headers["Cookie"] = cookie
url = f"{QUARK_API}{path}"
try:
async with session.request(method, url, json=data, headers=headers) as response:
return await response.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
pass
return None
async def _quark_candidates(
session: aiohttp.ClientSession, name: str, category: str, library: dict[str, str]
) -> list[dict[str, Any]]:
share_id = library["share_id"]
token_data = await _quark_json(
session,
"POST",
"/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=",
share_id=share_id,
data={"pwd_id": share_id, "passcode": ""},
)
stoken = ((token_data or {}).get("data") or {}).get("stoken")
if not stoken:
return []
queue = ["0"]
visited = set()
candidates = []
wanted = normalized_stem(name)
wanted_filename = basename(name).casefold()
preferred_folders = QUARK_CATEGORY_FOLDERS.get(category, {category})
while queue and len(visited) < 2000:
directory = queue.pop(0)
if directory in visited:
continue
visited.add(directory)
page = 1
while page <= 100:
query = (
f"/share/sharepage/detail?pr=ucpro&fr=pc&pwd_id={quote(share_id)}"
f"&stoken={quote(stoken, safe='')}&pdir_fid={quote(directory)}&force=0"
f"&_page={page}&_size=200&_sort=file_type:asc,file_name:asc"
)
detail = await _quark_json(session, "GET", query, share_id=share_id)
items = ((detail or {}).get("data") or {}).get("list", [])
if not isinstance(items, list):
break
for item in items:
item_name = str(item.get("file_name", ""))
if item.get("dir"):
if item_name.lower() in preferred_folders:
queue.insert(0, item["fid"])
else:
queue.append(item["fid"])
continue
if not item_name.lower().endswith(tuple(MODEL_EXTENSIONS)):
continue
confidence = SequenceMatcher(None, wanted, normalized_stem(item_name)).ratio()
if item_name.casefold() == wanted_filename or confidence >= 0.62:
candidates.append(
{
"provider": library["name"],
"name": item_name,
"url": library["url"],
"confidence": 1.0 if item_name.casefold() == wanted_filename else round(confidence, 3),
"size": _size_value(item.get("size")),
"quark": {
"share_id": share_id,
"fid": item.get("fid"),
"fid_token": _quark_token(item.get("share_fid_token")),
"filename": item_name,
},
}
)
if item_name.casefold() == wanted_filename:
return candidates
if len(items) < 200:
break
page += 1
return candidates
@PromptServer.instance.routes.post("/findmodels/scan")
async def scan_models(request: web.Request) -> web.Response:
payload = _annotate_registered_widget_categories(await request.json())
quick = bool(payload.get("quick"))
registered_categories = getattr(folder_paths, "folder_names_and_paths", {})
result = analyze(
payload,
folder_paths.get_filename_list,
registered_categories.keys() if isinstance(registered_categories, dict) else (),
)
external_root = _load_external_folder()
external_index = await _external_candidates_index(
external_root,
{str(model.get("name") or "") for model in result["models"]},
{
str(model.get("name") or ""): str(model.get("category") or "")
for model in result["models"]
},
)
for model in result["models"]:
candidates = external_index.get(basename(model["name"]).lower(), [])
model["category"] = _resolve_model_category(model, candidates)
candidates.sort(
key=lambda item: (
item.get("category_hint") != model.get("category"),
len(Path(item["path"]).parts),
)
)
active_job = next(
(
job
for job in DOWNLOAD_JOBS.values()
if basename(str(job.get("original") or job.get("filename") or "")).lower()
== basename(model["name"]).lower()
and job.get("status") in {"queued", "downloading", "completed"}
),
None,
)
model["external_candidates"] = candidates
model["size"] = (
model.get("source_size")
or (candidates[0]["size"] if candidates else None)
or (KNOWN_MODEL_SOURCES.get(basename(model["name"]).lower()) or {}).get("size")
or ((active_job or {}).get("total"))
)
result["external_folder"] = str(external_root) if external_root else ""
try:
import nodes
registered = nodes.NODE_CLASS_MAPPINGS.keys()
except (ImportError, AttributeError):
registered = ()
result["missing_nodes"] = missing_workflow_node_types(payload.get("nodes", []), registered)
result["missing_node_packages"] = missing_workflow_node_packages(payload.get("nodes", []), registered)
result["missing_node_references"] = {
node_type: [
str(node.get("id"))
for node in payload.get("nodes", [])
if isinstance(node, dict)
and node.get("type") == node_type
and node.get("id") is not None
]
for node_type in result["missing_nodes"]
}
result["missing_node_candidates"] = {}
result["quick"] = quick
return web.json_response(result)
@PromptServer.instance.routes.post("/findmodels/external-folder/select")
async def select_external_folder(request: web.Request) -> web.Response:
try:
path = await asyncio.to_thread(_choose_external_folder)
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
raise web.HTTPBadRequest(text=str(error)) from error
if path is None:
return web.json_response({"path": "", "cancelled": True})
await asyncio.to_thread(_save_external_folder, path)
return web.json_response({"path": str(path), "cancelled": False})
@PromptServer.instance.routes.get("/findmodels/quark-auth")
async def get_quark_auth(request: web.Request) -> web.Response:
return web.json_response({
"configured": bool(_load_quark_cookie()),
"libraries": [
{"name": library["name"], "url": library["url"], "share_id": library["share_id"]}
for library in QUARK_MODEL_LIBRARIES
],
})
@PromptServer.instance.routes.post("/findmodels/quark-libraries/check")
async def check_quark_libraries(request: web.Request) -> web.Response:
results = []
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
for library in QUARK_MODEL_LIBRARIES:
token_data = await _quark_json(
session,
"POST",
"/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=",
share_id=library["share_id"],
data={"pwd_id": library["share_id"], "passcode": ""},
)
results.append({
"name": library["name"],
"url": library["url"],
"reachable": bool(((token_data or {}).get("data") or {}).get("stoken")),
})
return web.json_response({"libraries": results, "configured": bool(_load_quark_cookie())})
@PromptServer.instance.routes.post("/findmodels/quark-auth")
async def set_quark_auth(request: web.Request) -> web.Response:
payload = await request.json()
await asyncio.to_thread(_save_quark_cookie, str(payload.get("cookie", "")))
return web.json_response({"configured": bool(_load_quark_cookie())})
@PromptServer.instance.routes.post("/findmodels/external-move")
async def move_external_model(request: web.Request) -> web.Response:
payload = await request.json()
result = await asyncio.to_thread(
_move_external_model,
str(payload.get("source", "")),
str(payload.get("name", "")),
str(payload.get("category", "")),
_load_external_folder(),
)
return web.json_response(result)
async def _official_node_map(session: aiohttp.ClientSession) -> dict[str, Any]:
data = await _get_json(session, COMFY_MANAGER_NODE_MAP_URL)
return data if isinstance(data, dict) else {}
@PromptServer.instance.routes.post("/findnodes/candidates")
async def find_node_candidates(request: web.Request) -> web.Response:
payload = await request.json()
node_type = str(payload.get("node_type", "")).strip()
package_id = str(payload.get("package_id", "")).strip()
if not node_type and not package_id:
raise web.HTTPBadRequest(text="Missing node type or package id")
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
node_map = await _official_node_map(session)
candidates = github_fallback_candidates(package_id, node_type, node_map)
search_term = package_id or node_type
return web.json_response(
{
"node_type": node_type,
"package_id": package_id,
"candidates": candidates[:8],
"github_search_url": f"https://github.com/search?q={quote(f'{search_term} ComfyUI')}&type=repositories",
}
)
@PromptServer.instance.routes.post("/findnodes/install")
async def install_node_plugin(request: web.Request) -> web.Response:
payload = await request.json()
node_type = str(payload.get("node_type", "")).strip()
package_id = str(payload.get("package_id", "")).strip()
plugin_id = str(payload.get("plugin_id", "")).strip()
custom_url = str(payload.get("repo_url", "")).strip().rstrip("/")
install_dependencies = bool(payload.get("install_dependencies", True))
if (not node_type and not package_id) or (not plugin_id and not custom_url):
raise web.HTTPBadRequest(text="Missing node type, package id, or plugin id")
if custom_url:
if not allowed_repo_url(custom_url):
raise web.HTTPBadRequest(text="自定义地址必须是完整的 GitHub 仓库 HTTPS 链接")
repo_id = "/".join(urlparse(custom_url).path.strip("/").split("/")[:2])
candidate = {
"id": repo_id,
"title": repo_id,
"repo_url": custom_url,
"installable": True,
"pip": [],
"apt_dependency": [],
}
else:
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
node_map = await _official_node_map(session)
candidates = github_fallback_candidates(package_id, node_type, node_map)
candidate = next((item for item in candidates if item["id"] == plugin_id), None)
if not candidate:
raise web.HTTPBadRequest(text="插件不在工作流 aux_id 或 ComfyUI-Manager 官方映射的可信结果中")
try:
result = await asyncio.to_thread(
install_plugin, folder_paths, candidate, install_dependencies
)
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
raise web.HTTPBadRequest(text=str(error)) from error
return web.json_response(result)
@PromptServer.instance.routes.post("/findmodels/sources")
async def find_sources(request: web.Request) -> web.Response:
payload = await request.json()
name = str(payload.get("name", "")).strip()
category = str(payload.get("category", "unknown")).strip()
if not name:
raise web.HTTPBadRequest(text="Missing model name")
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
results = await asyncio.gather(
_civitai_candidates(session, name),
_huggingface_candidates(session, name),
*(_quark_candidates(session, name, category, library) for library in QUARK_MODEL_LIBRARIES),
return_exceptions=True,
)
civitai = results[0] if isinstance(results[0], list) else []
huggingface = results[1] if isinstance(results[1], list) else []
quark_results = [result for result in results[2:] if isinstance(result, list)]
quark = [candidate for result in quark_results for candidate in result]
exact_web = [candidate for candidate in civitai + huggingface if _exact_model_name(name, candidate["name"])]
known = KNOWN_MODEL_SOURCES.get(basename(name).lower())
verified_known = []
if known:
checked_known = await _validate_web_candidate(session, dict(known))
if checked_known:
verified_known.append(checked_known)
exact_quark = [candidate for candidate in quark if _exact_model_name(name, candidate["name"])]
checked_web = await asyncio.gather(
*(_validate_web_candidate(session, candidate) for candidate in exact_web),
return_exceptions=True,
)
exact_candidates = [
candidate for candidate in [*verified_known, *checked_web, *exact_quark] if isinstance(candidate, dict)
]
for candidate in exact_candidates:
candidate["exact"] = True
candidate["downloadable"] = True
provider_rank = {"Hugging Face": 0, "Civitai": 1, "夸克模型库 1": 2, "夸克模型库 2": 3}
exact_candidates.sort(
key=lambda candidate: (
-float(candidate.get("confidence") or 0),
provider_rank.get(str(candidate.get("provider") or ""), 9),
)
)
similar_candidates = sorted(
[
{**candidate, "exact": False, "downloadable": False}
for candidate in [*civitai, *huggingface, *quark]
if not _exact_model_name(name, candidate.get("name", ""))
and float(candidate.get("confidence") or 0) >= 0.62
],
key=lambda candidate: (
-float(candidate.get("confidence") or 0),
provider_rank.get(str(candidate.get("provider") or ""), 9),
),
)
candidates = [*exact_candidates, *similar_candidates][:12]
size = next((_size_value(candidate.get("size")) for candidate in candidates if candidate.get("size")), None)
return web.json_response({
"name": name,
"size": size,
"candidates": candidates,
"exact_count": len(exact_candidates),
"search_urls": {
"huggingface": f"https://huggingface.co/models?search={quote(_safe_query(name))}",
"civitai": f"https://civitai.com/search/models?sortBy=models_v9&query={quote(_safe_query(name))}",
},
})
async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str, Any]) -> str:
share_id = str(payload.get("share_id", ""))
filename = basename(str(payload.get("filename", "")))
if filename:
refreshed = await _quark_candidates(
session,
filename,
"unknown",
{"share_id": share_id, "name": "Quark", "url": f"https://pan.quark.cn/s/{share_id}"},
)
exact = next((item for item in refreshed if _exact_model_name(filename, item.get("name", ""))), None)
if exact:
payload = exact["quark"]
token_data = await _quark_json(
session,
"POST",
"/share/sharepage/token?pr=ucpro&fr=pc&uc_param_str=",
share_id=share_id,
data={"pwd_id": share_id, "passcode": ""},
)
stoken = ((token_data or {}).get("data") or {}).get("stoken")
fid = str(payload.get("fid") or "").strip()
fid_token = _quark_token(payload.get("fid_token") or payload.get("fids_token"))
if not stoken:
raise web.HTTPBadGateway(text="夸克分享 token 获取失败,分享链接可能失效或需要重新登录")
if not fid or not fid_token:
raise web.HTTPBadGateway(text="夸克文件 token 缺失,正在使用的分享文件信息已经失效")
request_data = {
"fids": [fid],
"pwd_id": share_id,
"stoken": stoken,
"fid_token": [fid_token],
}
errors = []
for endpoint in (
"/file/download?pr=ucpro&fr=pc&uc_param_str=",
"/file/share/download?pr=ucpro&fr=pc&uc_param_str=",
):
download = await _quark_json(session, "POST", endpoint, share_id=share_id, data=request_data)
if download and (download.get("message") or download.get("code")):
errors.append(f"{download.get('code', '')}: {download.get('message', '')}".strip(": "))
entries = (download or {}).get("data") or []
url = entries[0].get("download_url") if entries else None
if _allowed_quark_download_url(url):
return url
detail = errors[0] if errors else "需要登录或文件超过公开下载大小限制"
if detail.startswith("31001"):
detail = "夸克要求登录。请在设置中保存已登录 pan.quark.cn 的 Cookie 后重试"
elif detail.startswith("41020") or "token校验" in detail.lower():
detail = "夸克文件 token 校验失败,插件已重新读取分享目录但服务端仍拒绝"
elif detail.startswith("23018") or "download file size limit" in detail.lower():
detail = "夸克限制该大文件通过公开分享直链下载;请在设置中保存有效登录 Cookie 后重试,或使用已验证的同名备用来源"
raise web.HTTPBadGateway(text=f"夸克拒绝直链下载:{detail}")
async def _direct_web_fallback(session: aiohttp.ClientSession, filename: str) -> dict[str, Any] | None:
"""Find a verified, exact-name direct URL when a cloud-share download fails."""
known = KNOWN_MODEL_SOURCES.get(basename(filename).lower())
if known:
checked = await _validate_web_candidate(session, dict(known))
if checked:
return checked
civitai, huggingface = await asyncio.gather(
_civitai_candidates(session, filename),
_huggingface_candidates(session, filename),
)
exact = [
candidate
for candidate in [*huggingface, *civitai]
if _exact_model_name(filename, candidate.get("name", ""))
]
checked = await asyncio.gather(
*(_validate_web_candidate(session, candidate) for candidate in exact),
return_exceptions=True,
)
return next((candidate for candidate in checked if isinstance(candidate, dict)), None)
async def _download_model_payload(payload: dict[str, Any], progress: dict[str, Any] | None = None) -> dict[str, Any]:
url = str(payload.get("url", "")).strip()
quark = payload.get("quark")
category = str(payload.get("category", "")).strip()
filename = _safe_filename(str(payload.get("filename", "")))
expected_size = _size_value(payload.get("size"))
if not quark and not _allowed_download_url(url):
raise web.HTTPBadRequest(text="Only approved HTTPS model providers are allowed")
target_dir = _target_directory(category)
target = (target_dir / filename).resolve()
if target.parent != target_dir:
raise web.HTTPBadRequest(text="Invalid target path")
if target.exists():
raise web.HTTPConflict(text=f"Model already exists: {target}")
temp_path: Path | None = None
persistent_partial = progress is not None
try:
if progress is not None:
temp_path = Path(progress.get("temp_path") or (target_dir / f".{filename}.{progress['id']}.part"))
progress["temp_path"] = str(temp_path)
downloaded = temp_path.stat().st_size if temp_path.exists() else 0
progress.update(
status="downloading",
filename=filename,
category=category,
downloaded=downloaded,
total=progress.get("total") or expected_size,
error=None,
updated_at=time.time(),
)
else:
fd, temp_name = tempfile.mkstemp(prefix=f".{filename}.", suffix=".part", dir=target_dir)
os.close(fd)
temp_path = Path(temp_name)
downloaded = 0
async with aiohttp.ClientSession(timeout=DOWNLOAD_TIMEOUT, trust_env=True) as session:
if isinstance(quark, dict):
quark.setdefault("filename", filename)
try:
url = await _quark_download_url(session, quark)
except web.HTTPException as quark_error:
fallback = await _direct_web_fallback(session, filename)
if not fallback:
raise web.HTTPBadGateway(
text=f"{quark_error.text};未找到文件名完全一致且可验证的其他直链"
) from quark_error
url = str(fallback["url"])
expected_size = expected_size or _size_value(fallback.get("size"))
if progress is not None:
progress.update(provider=fallback.get("provider"), fallback_from="Quark", updated_at=time.time())
headers = {"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.14.1", "Referer": url}
if isinstance(quark, dict):
headers.update({
"Origin": "https://pan.quark.cn",
"Referer": f"https://pan.quark.cn/s/{quark.get('share_id', '')}",
"User-Agent": QUARK_USER_AGENT,
})
if _load_quark_cookie():
headers["Cookie"] = _load_quark_cookie()
if downloaded:
headers["Range"] = f"bytes={downloaded}-"
async with session.get(
url,
headers=headers,
allow_redirects=True,
) as response:
allowed = _allowed_download_url(str(response.url)) or _allowed_quark_download_url(str(response.url))
if response.status not in {200, 206} or not allowed:
raise web.HTTPBadGateway(text=f"Download failed with HTTP {response.status}")
content_type = response.headers.get("Content-Type", "").lower()
if "text/html" in content_type or "application/json" in content_type:
raise web.HTTPBadGateway(text="下载地址返回了网页或错误信息,而不是模型文件")
if downloaded and response.status != 206:
downloaded = 0
response_size = _size_value(
response.headers.get("X-Linked-Size")
or response.headers.get("X-File-Size")
or response.headers.get("Content-Length")
)
total = (
expected_size
or _content_range_total(response.headers.get("Content-Range"))
or ((downloaded + response_size) if downloaded and response_size else response_size)
)
if progress is not None:
progress.update(total=total, updated_at=time.time())
with temp_path.open("ab" if downloaded else "wb") as output:
async for chunk in response.content.iter_chunked(1024 * 1024):
output.write(chunk)
downloaded += len(chunk)
if progress is not None:
progress.update(downloaded=downloaded, total=total, updated_at=time.time())
if not _is_model_payload(temp_path):
raise web.HTTPBadGateway(text="下载结果不是有效模型文件,可能是登录页面、错误信息或 Git LFS 指针")
temp_path.replace(target)
tolerance = max(1024 * 1024, int(expected_size * 0.001)) if expected_size else 0
if expected_size and abs(target.stat().st_size - expected_size) > tolerance:
target.unlink()
raise web.HTTPBadGateway(text="下载文件大小与来源不一致,已删除不完整文件")
_clear_filename_cache(category)
except web.HTTPException:
raise
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as error:
raise web.HTTPBadGateway(text=f"Download failed: {error}") from error
finally:
if temp_path and temp_path.exists() and not persistent_partial:
temp_path.unlink()
return {
"downloaded": True,
"filename": filename,
"relative_name": filename,
"category": category,
"path": str(target),
"size": target.stat().st_size,
}
async def _run_download_job(job_id: str, payload: dict[str, Any]) -> None:
progress = DOWNLOAD_JOBS[job_id]
try:
progress.setdefault("started_at", time.time())
result = await _download_model_payload(payload, progress)
progress.update(status="completed", result=result, downloaded=result["size"], total=result["size"])
except asyncio.CancelledError:
if progress.get("status") != "cancelled":
progress.update(status="paused")
except Exception as error:
progress.update(status="failed", error=getattr(error, "text", None) or str(error))
finally:
progress["updated_at"] = time.time()
DOWNLOAD_TASKS.pop(job_id, None)
def _purge_download_jobs() -> None:
cutoff = time.time() - 3600
for job_id, job in list(DOWNLOAD_JOBS.items()):
if job.get("status") in {"completed", "failed", "cancelled"} and job.get("updated_at", 0) < cutoff:
DOWNLOAD_JOBS.pop(job_id, None)
async def _cancel_download_task(job_id: str) -> None:
task = DOWNLOAD_TASKS.get(job_id)
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
def _public_download_job(job: dict[str, Any]) -> dict[str, Any]:
public = {key: value for key, value in job.items() if key not in {"payload", "temp_path"}}
started_at = _size_value(job.get("started_at"))
downloaded = _size_value(job.get("downloaded")) or 0
total = _size_value(job.get("total"))
if started_at:
elapsed = max(0.0, time.time() - started_at)
speed = downloaded / elapsed if elapsed > 0 and downloaded > 0 else 0
public["elapsed"] = round(elapsed, 1)
public["speed"] = round(speed, 1)
public["eta"] = round((total - downloaded) / speed, 1) if total and speed > 0 and total > downloaded else None
return public
@PromptServer.instance.routes.post("/findmodels/download/start")
async def start_download_model(request: web.Request) -> web.Response:
payload = await request.json()
_safe_filename(str(payload.get("filename", "")))
category = str(payload.get("category", "")).strip()
_target_directory(category)
if not payload.get("quark") and not _allowed_download_url(str(payload.get("url", "")).strip()):
raise web.HTTPBadRequest(text="Only approved HTTPS model providers are allowed")
_purge_download_jobs()
filename = basename(str(payload.get("filename", "")))
existing = next(
(
job
for job in DOWNLOAD_JOBS.values()
if job.get("filename", "").lower() == filename.lower()
and job.get("category") == category
and job.get("status") in {"queued", "downloading", "paused", "failed"}
),
None,
)
if existing:
return web.json_response(_public_download_job(existing))
job_id = uuid.uuid4().hex
DOWNLOAD_JOBS[job_id] = {
"id": job_id,
"status": "queued",
"filename": filename,
"category": category,
"original": str(payload.get("original", "")),
"node_id": payload.get("node_id"),
"widget": payload.get("widget"),
"downloaded": 0,
"total": _size_value(payload.get("size")),
"created_at": time.time(),
"updated_at": time.time(),
"payload": dict(payload),
}
DOWNLOAD_TASKS[job_id] = asyncio.create_task(_run_download_job(job_id, payload))
return web.json_response(_public_download_job(DOWNLOAD_JOBS[job_id]))
@PromptServer.instance.routes.get("/findmodels/download/jobs")
async def download_model_jobs(request: web.Request) -> web.Response:
_purge_download_jobs()
jobs = sorted(DOWNLOAD_JOBS.values(), key=lambda job: job.get("created_at", 0), reverse=True)
return web.json_response({"jobs": [_public_download_job(job) for job in jobs]})
@PromptServer.instance.routes.post("/findmodels/download/pause")
async def pause_download_model(request: web.Request) -> web.Response:
job_id = str((await request.json()).get("job_id", "")).strip()
job = DOWNLOAD_JOBS.get(job_id)
if not job:
raise web.HTTPNotFound(text="Download task not found")
if job.get("status") not in {"queued", "downloading"}:
raise web.HTTPConflict(text="Only an active download can be paused")
job.update(status="pausing", updated_at=time.time())
await _cancel_download_task(job_id)
job.update(status="paused", updated_at=time.time())
return web.json_response(_public_download_job(job))
@PromptServer.instance.routes.post("/findmodels/download/resume")
async def resume_download_model(request: web.Request) -> web.Response:
job_id = str((await request.json()).get("job_id", "")).strip()
job = DOWNLOAD_JOBS.get(job_id)
if not job:
raise web.HTTPNotFound(text="Download task not found")
if job.get("status") not in {"paused", "failed"}:
raise web.HTTPConflict(text="Only a paused or failed download can be resumed")
payload = job.get("payload")
if not isinstance(payload, dict):
raise web.HTTPConflict(text="Download task cannot be resumed")
job.update(status="queued", error=None, updated_at=time.time())
DOWNLOAD_TASKS[job_id] = asyncio.create_task(_run_download_job(job_id, payload))
return web.json_response(_public_download_job(job))
@PromptServer.instance.routes.post("/findmodels/download/cancel")
async def cancel_download_model(request: web.Request) -> web.Response:
job_id = str((await request.json()).get("job_id", "")).strip()
job = DOWNLOAD_JOBS.get(job_id)
if not job:
raise web.HTTPNotFound(text="Download task not found")
if job.get("status") == "completed":
raise web.HTTPConflict(text="A completed download cannot be cancelled")
job.update(status="cancelled", updated_at=time.time())
await _cancel_download_task(job_id)
temp_value = str(job.get("temp_path", "")).strip()
if temp_value:
temp_path = Path(temp_value)
if temp_path.is_file():
temp_path.unlink()
job.update(downloaded=0, updated_at=time.time())
return web.json_response(_public_download_job(job))