Files
tomasz-syn-grzegorza 73e4fc005e Add stack Update, ComfyUI model manager, and slim ComfyUI stack.
Server UI gains Update on stack cards, ComfyUI Models tab with workflow
scan and downloads, and centralized comfyui_config. Model catalog and
download scripts move from stacks/comfyui to server-ui so ComfyUI stays a
minimal Docker wrapper for easier image updates.
2026-07-05 18:45:17 +00:00

859 lines
28 KiB
Python

"""ComfyUI model catalog and download jobs for Server UI."""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import threading
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import yaml
from comfyui_config import (
COMFYUI_CONTAINER,
catalog_path as comfyui_catalog_path,
host_models_root,
host_to_container_model_path,
host_workflows_dir,
scripts_dir as comfyui_scripts_dir,
stack_env_path as comfyui_stack_env_path,
)
from comfyui_manager_catalog import load_manager_index
from workflow_scanner import find_model_file, scan_workflows
log = logging.getLogger(__name__)
WORKFLOW_MODEL_PREFIX = "wf:"
CATEGORY_DIRS: dict[str, str] = {
"checkpoint": "checkpoints",
"lora": "loras",
"vae": "vae",
"controlnet": "controlnet",
"upscale": "upscale_models",
"latent_upscale": "latent_upscale_models",
"text_encoder": "text_encoders",
"diffusion_models": "diffusion_models",
"clip": "clip",
"unet": "unet",
"unknown": "checkpoints",
}
LOG_BUFFER_MAX = 256 * 1024
JOB_TTL_SECONDS = 3600
PROGRESS_RE = re.compile(r"(\d{1,3})%")
_job_manager: DownloadJobManager | None = None
class ComfyUIModelError(Exception):
pass
class ModelNotFoundError(ComfyUIModelError):
pass
class JobNotFoundError(ComfyUIModelError):
pass
class JobConflictError(ComfyUIModelError):
pass
class DeleteModelError(ComfyUIModelError):
pass
def models_root(data_root: Path) -> Path:
return host_models_root(data_root)
def workflows_dir(data_root: Path) -> Path:
return host_workflows_dir(data_root)
def workflow_model_id(filename: str) -> str:
return f"{WORKFLOW_MODEL_PREFIX}{filename}"
def is_workflow_model_id(model_id: str) -> bool:
return model_id.startswith(WORKFLOW_MODEL_PREFIX)
def resolve_relative_model_path(relative_path: str, data_root: Path) -> Path:
rel = relative_path.strip().lstrip("/")
if not rel or ".." in Path(rel).parts:
raise DeleteModelError(f"Invalid relative model path: {relative_path}")
dest = models_root(data_root) / rel
if not _is_under_models_root(dest, data_root):
raise DeleteModelError(f"Path outside models root: {dest}")
return dest
def _validate_download_url(url: str) -> None:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ComfyUIModelError("URL must start with http:// or https://")
def _catalog_by_filename(catalog_path: Path) -> dict[str, dict[str, Any]]:
by_filename: dict[str, dict[str, Any]] = {}
for entry in load_catalog(catalog_path):
filename = entry.get("filename")
model_id = entry.get("id")
if filename and model_id:
by_filename[str(filename)] = entry
return by_filename
def build_workflow_required(
catalog_path: Path,
data_root: Path,
active_by_model: dict[str, str],
jobs: dict[str, DownloadJob] | None = None,
) -> tuple[list[dict[str, Any]], int, str]:
"""Build workflow-required model list, excluding catalog entries (dedup)."""
jobs = jobs or {}
wf_dir = workflows_dir(data_root)
refs, workflows_scanned = scan_workflows(wf_dir)
catalog_filenames = {
str(e.get("filename", "")) for e in load_catalog(catalog_path) if e.get("filename")
}
manager_index = load_manager_index(data_root)
root = models_root(data_root)
items: list[dict[str, Any]] = []
for ref in refs:
if ref.filename in catalog_filenames:
continue
model_id = workflow_model_id(ref.filename)
found = find_model_file(root, ref.filename)
source = "none"
download_url: str | None = None
save_path: str | None = None
catalog_id: str | None = None
size_gb: Any = None
note = ""
catalog_entry = _catalog_by_filename(catalog_path).get(ref.filename)
if catalog_entry:
source = "catalog"
catalog_id = str(catalog_entry.get("id"))
download_url = catalog_entry.get("download_url")
size_gb = catalog_entry.get("size_gb")
note = str(catalog_entry.get("note") or "")
dest = resolve_dest_path(catalog_entry, data_root)
else:
mgr = manager_index.get(ref.filename)
if mgr and mgr.get("url"):
source = "manager"
download_url = str(mgr["url"])
save_path = str(mgr.get("save_path") or category_dir(ref.category))
size_str = mgr.get("size")
if isinstance(size_str, str) and size_str.upper().endswith("GB"):
try:
size_gb = float(size_str.upper().replace("GB", "").strip())
except ValueError:
size_gb = None
note = str(mgr.get("description") or mgr.get("name") or "")
dest = root / save_path / ref.filename
else:
dest = root / category_dir(ref.category) / ref.filename
if found is not None:
dest = found
try:
relative_path = dest.relative_to(root).as_posix()
except ValueError:
relative_path = None
active_job: DownloadJob | None = None
job_id = active_by_model.get(model_id)
if job_id and job_id in jobs:
active_job = jobs[job_id]
state = _model_file_state(dest, size_gb, active_job)
items.append(
{
"id": model_id,
"name": ref.filename,
"filename": ref.filename,
"category": ref.category,
"workflows": ref.workflows,
"source": source,
"downloadable": source in ("catalog", "manager"),
"download_url": download_url,
"save_path": save_path,
"catalog_id": catalog_id,
"relative_path": relative_path,
"note": note,
"size_gb": size_gb,
**state,
}
)
return items, workflows_scanned, str(wf_dir)
def _expected_bytes(size_gb: Any) -> int | None:
if size_gb is None:
return None
try:
return int(float(size_gb) * 1_000_000_000)
except (TypeError, ValueError):
return None
def _is_under_models_root(path: Path, data_root: Path) -> bool:
root = models_root(data_root).resolve()
try:
path.resolve().relative_to(root)
return True
except ValueError:
return False
def _file_size(path: Path) -> int:
if not path.is_file():
return 0
return path.stat().st_size
def category_dir(category: str) -> str:
return CATEGORY_DIRS.get(category, category)
def resolve_dest_path(entry: dict[str, Any], data_root: Path) -> Path:
local_path = entry.get("local_path")
if local_path:
return Path(local_path)
category = entry.get("category", "")
filename = entry.get("filename", "")
subdir = category_dir(str(category))
return data_root / "apps" / "comfyui" / "models" / subdir / filename
def load_catalog(catalog_path: Path) -> list[dict[str, Any]]:
if not catalog_path.is_file():
raise ComfyUIModelError(f"Catalog not found: {catalog_path}")
with catalog_path.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
models = data.get("models") or []
if not isinstance(models, list):
raise ComfyUIModelError("Invalid catalog: models must be a list")
return models
def _model_file_state(
dest: Path,
size_gb: Any,
active_job: DownloadJob | None,
) -> dict[str, Any]:
size_bytes = _file_size(dest)
expected = _expected_bytes(size_gb)
download_info: dict[str, Any] | None = None
if active_job and active_job.status == "running":
download_info = {
"job_id": active_job.id,
"status": active_job.status,
"progress": active_job.progress,
}
installed = False
partial = False
if size_bytes > 0:
if active_job and active_job.status == "running":
partial = True
elif expected is None or size_bytes >= int(expected * 0.95):
installed = True
else:
partial = True
return {
"path": str(dest),
"size_bytes": size_bytes,
"expected_bytes": expected,
"installed": installed,
"partial": partial,
"download": download_info,
}
def _unlink_model_file(dest: Path, data_root: Path) -> bool:
if not dest.is_file():
return False
if not _is_under_models_root(dest, data_root):
raise DeleteModelError(f"Refusing to delete path outside models root: {dest}")
try:
dest.unlink()
if not dest.is_file():
return True
except OSError as exc:
log.warning("Host unlink failed for %s: %s", dest, exc)
container_path = host_to_container_model_path(dest, data_root)
try:
result = subprocess.run(
["docker", "exec", COMFYUI_CONTAINER, "rm", "-f", container_path],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
log.warning("docker exec rm failed: %s", result.stderr.strip())
except (OSError, subprocess.TimeoutExpired) as exc:
log.warning("docker exec rm error for %s: %s", container_path, exc)
return not dest.is_file()
def list_models(catalog_path: Path, data_root: Path) -> list[dict[str, Any]]:
"""Basic catalog list without active download jobs (legacy helper)."""
return _build_model_list(catalog_path, data_root, active_by_model={})
def _build_model_list(
catalog_path: Path,
data_root: Path,
active_by_model: dict[str, str],
jobs: dict[str, DownloadJob] | None = None,
) -> list[dict[str, Any]]:
jobs = jobs or {}
items: list[dict[str, Any]] = []
for entry in load_catalog(catalog_path):
model_id = entry.get("id")
if not model_id:
continue
dest = resolve_dest_path(entry, data_root)
active_job: DownloadJob | None = None
job_id = active_by_model.get(str(model_id))
if job_id and job_id in jobs:
active_job = jobs[job_id]
state = _model_file_state(dest, entry.get("size_gb"), active_job)
items.append(
{
"id": model_id,
"name": entry.get("name", model_id),
"category": entry.get("category", ""),
"source": entry.get("source", ""),
"size_gb": entry.get("size_gb"),
"note": entry.get("note", ""),
"filename": entry.get("filename", dest.name),
**state,
}
)
return items
def list_installed(data_root: Path) -> list[dict[str, Any]]:
models_root = data_root / "apps" / "comfyui" / "models"
if not models_root.is_dir():
return []
installed: list[dict[str, Any]] = []
for path in sorted(models_root.rglob("*")):
if not path.is_file():
continue
if path.name.startswith("."):
continue
rel = path.relative_to(models_root)
installed.append(
{
"path": str(path),
"relative_path": str(rel),
"category_dir": rel.parts[0] if rel.parts else "",
"filename": path.name,
"size_bytes": path.stat().st_size,
}
)
return installed
def disk_usage(data_root: Path) -> dict[str, Any]:
usage = shutil.disk_usage(data_root)
return {
"path": str(data_root),
"total_bytes": usage.total,
"used_bytes": usage.used,
"free_bytes": usage.free,
}
def _parse_progress(line: str, current: int) -> int:
match = PROGRESS_RE.search(line)
if match:
return max(current, min(100, int(match.group(1))))
lower = line.lower()
if "already exists" in lower:
return 100
if line.strip() == "Done.":
return 100
return current
@dataclass
class DownloadJob:
id: str
model_id: str
status: str = "running"
progress: int = 0
log: str = ""
error: str = ""
started_at: float = field(default_factory=time.time)
finished_at: float | None = None
file_dest: Path | None = None
_log_buffer: list[str] = field(default_factory=list)
_process: subprocess.Popen[str] | None = field(default=None, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
def append_log(self, chunk: str) -> None:
with self._lock:
self._log_buffer.append(chunk)
combined = "".join(self._log_buffer)
if len(combined) > LOG_BUFFER_MAX:
combined = combined[-LOG_BUFFER_MAX:]
self._log_buffer = [combined]
self.log = combined
for line in chunk.splitlines():
self.progress = _parse_progress(line, self.progress)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"model_id": self.model_id,
"status": self.status,
"progress": self.progress,
"log": self.log,
"error": self.error,
"started_at": self.started_at,
"finished_at": self.finished_at,
}
class DownloadJobManager:
def __init__(
self,
catalog_path: Path,
data_root: Path,
download_script: Path,
download_file_script: Path,
stack_env_path: Path | None = None,
job_ttl: int = JOB_TTL_SECONDS,
) -> None:
self.catalog_path = catalog_path
self.data_root = data_root
self.download_script = download_script
self.download_file_script = download_file_script
self.stack_env_path = stack_env_path
self.job_ttl = job_ttl
self._jobs: dict[str, DownloadJob] = {}
self._active_by_model: dict[str, str] = {}
self._lock = threading.Lock()
def _catalog_by_id(self) -> dict[str, dict[str, Any]]:
return {str(m["id"]): m for m in load_catalog(self.catalog_path) if m.get("id")}
def _cleanup_old_jobs(self) -> None:
cutoff = time.time() - self.job_ttl
with self._lock:
expired = [
jid
for jid, job in self._jobs.items()
if job.finished_at is not None and job.finished_at < cutoff
]
for jid in expired:
job = self._jobs.pop(jid, None)
if job and job.model_id in self._active_by_model:
if self._active_by_model.get(job.model_id) == jid:
del self._active_by_model[job.model_id]
def get_job(self, job_id: str) -> DownloadJob:
self._cleanup_old_jobs()
job = self._jobs.get(job_id)
if not job:
raise JobNotFoundError(f"Unknown job: {job_id}")
return job
def list_models_with_state(self) -> list[dict[str, Any]]:
self._cleanup_old_jobs()
with self._lock:
active = dict(self._active_by_model)
jobs = dict(self._jobs)
return _build_model_list(self.catalog_path, self.data_root, active, jobs)
def list_models_response(self) -> dict[str, Any]:
self._cleanup_old_jobs()
with self._lock:
active = dict(self._active_by_model)
jobs = dict(self._jobs)
models = _build_model_list(self.catalog_path, self.data_root, active, jobs)
workflow_required, workflows_scanned, workflows_dir_path = build_workflow_required(
self.catalog_path, self.data_root, active, jobs
)
return {
"models": models,
"workflow_required": workflow_required,
"workflows_scanned": workflows_scanned,
"workflows_dir": workflows_dir_path,
}
def _dest_for_model(self, model_id: str) -> Path:
catalog = self._catalog_by_id()
if model_id not in catalog:
raise ModelNotFoundError(f"Unknown model id: {model_id}")
dest = resolve_dest_path(catalog[model_id], self.data_root)
if not _is_under_models_root(dest, self.data_root):
raise DeleteModelError(f"Invalid model path: {dest}")
return dest
def _remove_model_file(self, model_id: str) -> bool:
dest = self._dest_for_model(model_id)
return _unlink_model_file(dest, self.data_root)
def delete_model(self, model_id: str) -> dict[str, Any]:
self._cleanup_old_jobs()
with self._lock:
job_id = self._active_by_model.get(model_id)
if job_id:
self.cancel_job(job_id, cleanup=True)
if is_workflow_model_id(model_id):
raise ModelNotFoundError(
f"Workflow model {model_id} must be deleted via relative_path"
)
removed = self._remove_model_file(model_id)
dest = self._dest_for_model(model_id)
return {
"model_id": model_id,
"removed": removed,
"path": str(dest),
}
def delete_model_file(self, relative_path: str) -> dict[str, Any]:
self._cleanup_old_jobs()
dest = resolve_relative_model_path(relative_path, self.data_root)
model_id = workflow_model_id(dest.name)
with self._lock:
job_id = self._active_by_model.get(model_id)
if job_id:
self.cancel_job(job_id, cleanup=True)
removed = _unlink_model_file(dest, self.data_root)
return {
"relative_path": relative_path,
"removed": removed,
"path": str(dest),
}
def start_download(self, model_id: str) -> DownloadJob:
self._cleanup_old_jobs()
catalog = self._catalog_by_id()
if model_id not in catalog:
raise ModelNotFoundError(f"Unknown model id: {model_id}")
entry = catalog[model_id]
dest = resolve_dest_path(entry, self.data_root)
if dest.is_file() and _model_file_state(dest, entry.get("size_gb"), None)["installed"]:
job = DownloadJob(
id=str(uuid.uuid4()),
model_id=model_id,
status="completed",
progress=100,
log=f"Already exists: {dest}\n",
finished_at=time.time(),
)
with self._lock:
self._jobs[job.id] = job
return job
with self._lock:
if model_id in self._active_by_model:
active_id = self._active_by_model[model_id]
active = self._jobs.get(active_id)
if active and active.status == "running":
raise JobConflictError(
f"Download already running for {model_id} (job {active_id})"
)
if not self.download_script.is_file():
raise ComfyUIModelError(f"Download script not found: {self.download_script}")
job = DownloadJob(id=str(uuid.uuid4()), model_id=model_id)
with self._lock:
self._jobs[job.id] = job
self._active_by_model[model_id] = job.id
thread = threading.Thread(
target=self._run_download,
args=(job,),
name=f"comfyui-download-{job.id[:8]}",
daemon=True,
)
thread.start()
return job
def start_file_download(
self,
url: str,
relative_path: str,
*,
model_id: str | None = None,
size_gb: Any = None,
) -> DownloadJob:
self._cleanup_old_jobs()
_validate_download_url(url)
dest = resolve_relative_model_path(relative_path, self.data_root)
wf_id = model_id or workflow_model_id(dest.name)
if dest.is_file() and _model_file_state(dest, size_gb, None)["installed"]:
job = DownloadJob(
id=str(uuid.uuid4()),
model_id=wf_id,
status="completed",
progress=100,
log=f"Already exists: {dest}\n",
finished_at=time.time(),
file_dest=dest,
)
with self._lock:
self._jobs[job.id] = job
return job
with self._lock:
if wf_id in self._active_by_model:
active_id = self._active_by_model[wf_id]
active = self._jobs.get(active_id)
if active and active.status == "running":
raise JobConflictError(
f"Download already running for {wf_id} (job {active_id})"
)
if not self.download_file_script.is_file():
raise ComfyUIModelError(
f"Download file script not found: {self.download_file_script}"
)
job = DownloadJob(
id=str(uuid.uuid4()),
model_id=wf_id,
file_dest=dest,
)
with self._lock:
self._jobs[job.id] = job
self._active_by_model[wf_id] = job.id
thread = threading.Thread(
target=self._run_file_download,
args=(job, url, dest),
name=f"comfyui-file-download-{job.id[:8]}",
daemon=True,
)
thread.start()
return job
def start_workflow_download(self, model_id: str) -> DownloadJob:
"""Download a workflow-required model using catalog or manager metadata."""
if not is_workflow_model_id(model_id):
raise ModelNotFoundError(f"Not a workflow model id: {model_id}")
with self._lock:
active = dict(self._active_by_model)
jobs = dict(self._jobs)
items, _, _ = build_workflow_required(
self.catalog_path, self.data_root, active, jobs
)
entry = next((m for m in items if m["id"] == model_id), None)
if not entry:
raise ModelNotFoundError(f"Unknown workflow model: {model_id}")
if not entry.get("downloadable"):
raise ComfyUIModelError(
f"No download URL for {entry.get('filename')} — add to catalog or ComfyUI-Manager"
)
if entry.get("catalog_id"):
return self.start_download(str(entry["catalog_id"]))
relative_path = entry.get("relative_path")
download_url = entry.get("download_url")
if not relative_path or not download_url:
raise ComfyUIModelError(f"Missing download metadata for {model_id}")
return self.start_file_download(
str(download_url),
str(relative_path),
model_id=model_id,
size_gb=entry.get("size_gb"),
)
def cancel_job(self, job_id: str, *, cleanup: bool = True) -> DownloadJob:
job = self.get_job(job_id)
if job.status == "running":
proc = job._process
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
job.status = "cancelled"
job.finished_at = time.time()
job.append_log("\nCancelled by user.\n")
with self._lock:
if self._active_by_model.get(job.model_id) == job.id:
del self._active_by_model[job.model_id]
if cleanup:
try:
if job.file_dest:
_unlink_model_file(job.file_dest, self.data_root)
job.append_log("Partial file removed.\n")
else:
self._remove_model_file(job.model_id)
job.append_log("Partial file removed.\n")
except ComfyUIModelError as exc:
job.append_log(f"Cleanup failed: {exc}\n")
return job
def _run_file_download(self, job: DownloadJob, url: str, dest: Path) -> None:
env = os.environ.copy()
env["DATA_ROOT"] = str(self.data_root)
if self.stack_env_path and self.stack_env_path.is_file():
for line in self.stack_env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in env:
env[key] = value
cmd = [
str(self.download_file_script),
"--url",
url,
"--dest",
str(dest),
]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
cwd=str(self.download_file_script.parent),
)
job._process = proc
assert proc.stdout is not None
for line in proc.stdout:
job.append_log(line)
rc = proc.wait()
if job.status == "cancelled":
return
if rc == 0:
job.status = "completed"
job.progress = 100
else:
job.status = "failed"
job.error = f"Download script exited with code {rc}"
except Exception as exc:
job.status = "failed"
job.error = str(exc)
job.append_log(f"\nERROR: {exc}\n")
log.exception("ComfyUI file download failed for %s", job.model_id)
finally:
job.finished_at = time.time()
job._process = None
with self._lock:
if self._active_by_model.get(job.model_id) == job.id:
del self._active_by_model[job.model_id]
def _run_download(self, job: DownloadJob) -> None:
env = os.environ.copy()
env["DATA_ROOT"] = str(self.data_root)
if self.stack_env_path and self.stack_env_path.is_file():
for line in self.stack_env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in env:
env[key] = value
cmd = [str(self.download_script), job.model_id]
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
cwd=str(self.download_script.parent),
)
job._process = proc
assert proc.stdout is not None
for line in proc.stdout:
job.append_log(line)
rc = proc.wait()
if job.status == "cancelled":
return
if rc == 0:
job.status = "completed"
job.progress = 100
else:
job.status = "failed"
job.error = f"Download script exited with code {rc}"
except Exception as exc:
job.status = "failed"
job.error = str(exc)
job.append_log(f"\nERROR: {exc}\n")
log.exception("ComfyUI download failed for %s", job.model_id)
finally:
job.finished_at = time.time()
job._process = None
with self._lock:
if self._active_by_model.get(job.model_id) == job.id:
del self._active_by_model[job.model_id]
def init_download_manager(
repo_root: Path,
data_root: Path,
job_ttl: int = JOB_TTL_SECONDS,
) -> DownloadJobManager:
global _job_manager
ui_root = Path(__file__).resolve().parent
catalog_path = comfyui_catalog_path(ui_root)
scripts = comfyui_scripts_dir(ui_root)
download_script = scripts / "download-model.sh"
download_file_script = scripts / "download-file.sh"
stack_env = comfyui_stack_env_path(repo_root)
_job_manager = DownloadJobManager(
catalog_path=catalog_path,
data_root=Path(data_root),
download_script=download_script,
download_file_script=download_file_script,
stack_env_path=stack_env if stack_env.is_file() else None,
job_ttl=job_ttl,
)
return _job_manager
def get_download_manager() -> DownloadJobManager:
if _job_manager is None:
raise ComfyUIModelError("Download manager not initialized")
return _job_manager