Files
mclaughlinrosemary927-Comfy…/find_models.py
T

606 lines
25 KiB
Python

from __future__ import annotations
import asyncio
import os
import re
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, normalized_stem
from .node_installer import (
COMFY_MANAGER_NODE_MAP_URL,
TE_MARKET_URL,
install_market_plugin,
market_candidates,
missing_node_types,
)
TIMEOUT = aiohttp.ClientTimeout(total=30)
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": "4680ac8665162", "url": "https://pan.quark.cn/s/4680ac8665162"},
)
QUARK_API = "https://drive-pc.quark.cn/1/clouddrive"
QUARK_CATEGORY_FOLDERS = {
"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 = {
"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",),
}
KNOWN_MODEL_SOURCES = {
"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]] = {}
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 _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 _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 _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)
if not aliases:
raise web.HTTPBadRequest(text=f"无法确定模型对应的 ComfyUI 文件夹: {category}")
for candidate in aliases:
try:
paths = folder_paths.get_folder_paths(candidate)
except Exception:
continue
if paths:
target = Path(paths[0]).resolve()
target.mkdir(parents=True, exist_ok=True)
return target
raise web.HTTPBadRequest(text=f"No configured model folder for category: {category}")
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)
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 = {"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.3.0", "Referer": f"https://pan.quark.cn/s/{share_id}"}
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)
preferred_folders = QUARK_CATEGORY_FOLDERS.get(category, {category})
while queue and len(visited) < 80 and len(candidates) < 8:
directory = queue.pop(0)
if directory in visited:
continue
visited.add(directory)
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"
"&_page=1&_size=200&_sort=file_type:asc,file_name:asc"
)
detail = await _quark_json(session, "GET", query, share_id=share_id)
for item in ((detail or {}).get("data") or {}).get("list", []):
item_name = str(item.get("file_name", ""))
if item.get("dir"):
if item_name.lower() in preferred_folders:
queue = [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 confidence >= 0.62:
candidates.append(
{
"provider": library["name"],
"name": item_name,
"confidence": round(confidence, 3),
"size": _size_value(item.get("size")),
"quark": {
"share_id": share_id,
"fid": item.get("fid"),
"fid_token": item.get("share_fid_token"),
},
}
)
return candidates
@PromptServer.instance.routes.post("/findmodels/scan")
async def scan_models(request: web.Request) -> web.Response:
payload = await request.json()
result = analyze(payload, folder_paths.get_filename_list)
try:
import nodes
registered = nodes.NODE_CLASS_MAPPINGS.keys()
except (ImportError, AttributeError):
registered = ()
result["missing_nodes"] = missing_node_types(
[node.get("type") for node in payload.get("nodes", []) if isinstance(node, dict)],
registered,
)
result["missing_node_candidates"] = {}
if result["missing_nodes"]:
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
entries, node_map = await asyncio.gather(_te_market_entries(session), _official_node_map(session))
result["missing_node_candidates"] = {
node_type: market_candidates(entries, node_type, node_map)[:8]
for node_type in result["missing_nodes"]
}
return web.json_response(result)
async def _te_market_entries(session: aiohttp.ClientSession) -> list[dict[str, Any]]:
data = await _get_json(session, TE_MARKET_URL)
return (data or {}).get("custom_nodes", [])
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()
if not node_type:
raise web.HTTPBadRequest(text="Missing node type")
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
entries, node_map = await asyncio.gather(_te_market_entries(session), _official_node_map(session))
candidates = market_candidates(entries, node_type, node_map)
return web.json_response(
{
"node_type": node_type,
"candidates": candidates[:8],
"github_search_url": f"https://github.com/search?q={quote(f'{node_type} 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()
plugin_id = str(payload.get("plugin_id", "")).strip()
if not node_type or not plugin_id:
raise web.HTTPBadRequest(text="Missing node type or plugin id")
async with aiohttp.ClientSession(timeout=SOURCE_TIMEOUT, trust_env=True) as session:
entries, node_map = await asyncio.gather(_te_market_entries(session), _official_node_map(session))
candidates = market_candidates(entries, node_type, node_map)
candidate = next((item for item in candidates if item["id"] == plugin_id), None)
if not candidate:
raise web.HTTPBadRequest(text="插件不在 TE 官方市场的精确节点匹配结果中")
try:
result = await asyncio.to_thread(install_market_plugin, folder_paths, candidate)
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 = [dict(known)] if known else []
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,
)
candidates = [
candidate for candidate in [*verified_known, *checked_web, *exact_quark] if isinstance(candidate, dict)
]
return web.json_response({"name": name, "candidates": candidates[:12]})
async def _quark_download_url(session: aiohttp.ClientSession, payload: dict[str, Any]) -> str:
share_id = str(payload.get("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")
request_data = {
"fids": [payload.get("fid")],
"pwd_id": share_id,
"stoken": stoken,
"fids_token": [payload.get("fid_token")],
}
errors = []
for endpoint in ("/file/download?pr=ucpro&fr=pc", "/file/share/download?pr=ucpro&fr=pc"):
download = await _quark_json(session, "POST", endpoint, share_id=share_id, data=request_data)
if download and download.get("message"):
errors.append(str(download["message"]))
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 "需要登录或文件超过公开下载大小限制"
raise web.HTTPBadGateway(text=f"夸克拒绝直链下载:{detail}")
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
try:
if progress is not None:
progress.update(
status="downloading",
filename=filename,
category=category,
downloaded=0,
total=expected_size,
updated_at=time.time(),
)
fd, temp_name = tempfile.mkstemp(prefix=f".{filename}.", suffix=".part", dir=target_dir)
os.close(fd)
temp_path = Path(temp_name)
async with aiohttp.ClientSession(timeout=DOWNLOAD_TIMEOUT, trust_env=True) as session:
if isinstance(quark, dict):
url = await _quark_download_url(session, quark)
async with session.get(
url,
headers={"User-Agent": "Mozilla/5.0 ComfyUI_FindModels/1.4.0", "Referer": url},
allow_redirects=True,
) as response:
allowed = _allowed_download_url(str(response.url)) or _allowed_quark_download_url(str(response.url))
if response.status != 200 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="下载地址返回了网页或错误信息,而不是模型文件")
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 response_size
downloaded = 0
if progress is not None:
progress.update(total=total, updated_at=time.time())
with temp_path.open("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():
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:
result = await _download_model_payload(payload, progress)
progress.update(status="completed", result=result, downloaded=result["size"], total=result["size"])
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"} and job.get("updated_at", 0) < cutoff:
DOWNLOAD_JOBS.pop(job_id, None)
@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()
job_id = uuid.uuid4().hex
DOWNLOAD_JOBS[job_id] = {
"id": job_id,
"status": "queued",
"filename": basename(str(payload.get("filename", ""))),
"category": category,
"downloaded": 0,
"total": _size_value(payload.get("size")),
"created_at": time.time(),
"updated_at": time.time(),
}
DOWNLOAD_TASKS[job_id] = asyncio.create_task(_run_download_job(job_id, payload))
return web.json_response(DOWNLOAD_JOBS[job_id])
@PromptServer.instance.routes.post("/findmodels/download/progress")
async def download_model_progress(request: web.Request) -> web.Response:
payload = await request.json()
job_id = str(payload.get("job_id", "")).strip()
job = DOWNLOAD_JOBS.get(job_id)
if not job:
raise web.HTTPNotFound(text="Download task not found")
return web.json_response(job)
@PromptServer.instance.routes.post("/findmodels/download")
async def download_model(request: web.Request) -> web.Response:
return web.json_response(await _download_model_payload(await request.json()))