Files
kenyonxu-ComfyUI_KaiUtilities/__init__.py
T
kenyonxu 37f508a2ed fix: support HTTP proxy and ensure non-empty error messages
- Enable trust_env=True in aiohttp ClientSession so Civitai API requests
  respect HTTP_PROXY/HTTPS_PROXY environment variables (required in regions
  where Civitai is blocked, e.g. mainland China behind mihomo/clash).
- Add _err_msg() helper to handle exceptions with empty str() (e.g.
  TimeoutError, certain aiohttp errors) by falling back to repr().
- Replace all bare str(e) in error responses with _err_msg(e) so users
  always see a meaningful error description.
2026-04-21 16:37:03 +08:00

234 lines
8.1 KiB
Python

import asyncio
import os
from datetime import datetime, timezone
import aiohttp
import folder_paths
from aiohttp import web
from .civitai_client import compute_file_hash, download_image
from .cache_manager import cache_manager
# Frontend JS directory -- ComfyUI serves all files in this dir to the browser
WEB_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
# Node exports -- required for ComfyUI to discover this node
from .nodes import KaiCivitaiLoRALoader
NODE_CLASS_MAPPINGS = {"KaiCivitaiLoRALoader": KaiCivitaiLoRALoader}
NODE_DISPLAY_NAME_MAPPINGS = {"KaiCivitaiLoRALoader": "Kai Civitai LoRA Loader"}
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"WEB_DIRECTORY",
]
# In-flight request lock state
_fetching_locks: dict[str, asyncio.Lock] = {}
_fetching_lock_refcounts: dict[str, int] = {}
_fetching_locks_lock = asyncio.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _err_msg(e: Exception) -> str:
"""Return a non-empty error message. Some exceptions (e.g. TimeoutError)
have an empty str() by default; fall back to repr() in that case."""
msg = str(e)
return msg if msg else repr(e)
def _normalize_civitai_response(raw: dict, file_hash: str) -> dict:
"""Convert Civitai API response to our cache schema."""
model = raw.get("model") or {}
images = []
for img in raw.get("images", []):
images.append({
"url": img.get("url", ""),
"local_path": None,
"type": img.get("type", "image"),
"nsfw": bool(img.get("nsfw", False)),
})
# Find matching file info for download URL (try uppercase SHA256, then lowercase)
download_url = ""
for f in raw.get("files", []):
hashes = f.get("hashes", {})
file_hashes = [
hashes.get("SHA256", "").lower(),
hashes.get("sha256", "").lower(),
]
if file_hash.lower() in file_hashes:
download_url = f.get("downloadUrl", "")
break
trained_words = raw.get("trainedWords") or []
return {
"hash": file_hash,
"modelId": raw.get("modelId"),
"name": raw.get("name", ""),
"description": model.get("description", ""),
"triggerWords": trained_words,
"images": images,
"baseModel": raw.get("baseModel", ""),
"downloadUrl": download_url,
"fetched_at": _now_iso(),
}
async def _do_fetch(lora_path: str, file_hash: str, force: bool = False) -> dict:
"""Core fetch logic: check cache -> fetch Civitai -> download images -> cache."""
if not force:
cached = cache_manager.read_cache(file_hash)
if cached is not None:
return {"success": True, "data": cached, "cached": True}
# Fetch from Civitai
from .civitai_client import fetch_model_by_hash
try:
raw = await fetch_model_by_hash(file_hash)
except aiohttp.ClientResponseError as e:
# Only cache placeholder for 404/not-found to avoid repeated 404s
if e.status == 404:
placeholder = {
"hash": file_hash,
"error": _err_msg(e),
"fetched_at": _now_iso(),
"triggerWords": [],
"images": [],
}
cache_manager.write_cache(file_hash, placeholder)
raise
except Exception:
# Don't cache for network errors, rate limits, etc. -- let user retry
raise
# Normalize Civitai response
data = _normalize_civitai_response(raw, file_hash)
# Download images
images = data.get("images", [])
for i, img in enumerate(images):
dest = cache_manager.get_image_path(file_hash, i)
success = await download_image(img["url"], dest)
if success:
img["local_path"] = dest
else:
img["local_path"] = None
cache_manager.write_cache(file_hash, data)
return {"success": True, "data": data, "cached": False}
async def fetch_civitai_info(request):
try:
body = await request.json()
except Exception:
return web.json_response({"success": False, "error": "Invalid JSON"}, status=400)
lora_name = body.get("lora_name")
force = body.get("force", False)
if not lora_name:
return web.json_response({"success": False, "error": "Missing lora_name"}, status=400)
allowed = folder_paths.get_filename_list("loras")
if lora_name not in allowed:
return web.json_response({"success": False, "error": "Invalid lora_name"}, status=400)
try:
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
except Exception as e:
return web.json_response({"success": False, "error": str(e)}, status=400)
try:
file_hash = await compute_file_hash(lora_path)
except Exception as e:
return web.json_response({"success": False, "error": f"Hash failed: {_err_msg(e)}"}, status=500)
# Get or create lock atomically with refcount
async with _fetching_locks_lock:
if file_hash not in _fetching_locks:
_fetching_locks[file_hash] = asyncio.Lock()
lock = _fetching_locks[file_hash]
_fetching_lock_refcounts[file_hash] = _fetching_lock_refcounts.get(file_hash, 0) + 1
try:
async with lock:
try:
result = await _do_fetch(lora_path, file_hash, force=force)
except aiohttp.ClientResponseError as e:
if e.status == 404:
cached = cache_manager.read_cache(file_hash)
if cached and "error" in cached:
return web.json_response({
"success": False,
"error": str(e),
"data": cached,
"cached": True,
}, status=404)
return web.json_response({"success": False, "error": _err_msg(e)}, status=e.status)
except Exception as e:
return web.json_response({"success": False, "error": _err_msg(e)}, status=500)
finally:
# Clean up lock when refcount reaches zero
async with _fetching_locks_lock:
_fetching_lock_refcounts[file_hash] -= 1
if _fetching_lock_refcounts[file_hash] == 0:
del _fetching_lock_refcounts[file_hash]
del _fetching_locks[file_hash]
return web.json_response(result)
async def get_cache(request):
hash_val = request.match_info["hash"]
cached = cache_manager.read_cache(hash_val)
if cached is None:
return web.json_response({"exists": False}, status=404)
return web.json_response({"exists": True, "data": cached})
async def get_image(request):
"""Serve a cached preview image securely."""
hash_val = request.match_info["hash"]
try:
index = int(request.match_info["index"])
except ValueError:
return web.json_response({"error": "Invalid index"}, status=400)
# Validate hash format (hex only, 64 chars) to prevent path traversal
if len(hash_val) != 64 or not all(c in "0123456789abcdefABCDEF" for c in hash_val):
return web.json_response({"error": "Invalid hash"}, status=400)
image_path = cache_manager.get_image_path(hash_val, index)
cache_dir = os.path.realpath(cache_manager.cache_dir)
abs_image_path = os.path.realpath(image_path)
# Security: ensure the resolved path is within the cache directory
if not abs_image_path.startswith(cache_dir + os.sep):
return web.json_response({"error": "Forbidden"}, status=403)
if not os.path.exists(abs_image_path):
return web.json_response({"error": "Not found"}, status=404)
return web.FileResponse(abs_image_path)
# Register HTTP routes only when ComfyUI server is available
try:
from server import PromptServer
except Exception:
PromptServer = None # type: ignore[misc,assignment]
if PromptServer is not None:
PromptServer.instance.routes.post("/kai/civitai/fetch")(fetch_civitai_info)
PromptServer.instance.routes.get("/kai/civitai/cache/{hash}")(get_cache)
PromptServer.instance.routes.get("/kai/civitai/image/{hash}/{index}")(get_image)