- Created .gitkeep file in docs/media to track the directory. - Added ApiClient.ts service file for API interactions. - Introduced nd_super_nodes_update_cache.json with metadata for ND Super Nodes v1.3.0, including installation instructions and release details.
230 lines
7.6 KiB
Python
230 lines
7.6 KiB
Python
"""Version and update helpers for ND Super Nodes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Optional
|
|
|
|
try:
|
|
import aiohttp
|
|
except Exception: # pragma: no cover - optional dependency on some installs
|
|
aiohttp = None # type: ignore
|
|
|
|
try:
|
|
import folder_paths # type: ignore
|
|
except Exception: # pragma: no cover - folder_paths unavailable outside ComfyUI
|
|
folder_paths = None # type: ignore
|
|
|
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
VERSION_FILE = os.path.join(ROOT_DIR, "version.json")
|
|
CACHE_FILENAME = "nd_super_nodes_update_cache.json"
|
|
CHECK_INTERVAL_SECONDS = 24 * 60 * 60 # once per day per session
|
|
GITHUB_RELEASE_URL = "https://api.github.com/repos/HenkDz/nd-super-nodes/releases/latest"
|
|
USER_AGENT = "ND-Super-Nodes-Updater"
|
|
|
|
_cache_data: Optional[Dict[str, Any]] = None
|
|
_cache_lock = asyncio.Lock()
|
|
_cache_timestamp: float = 0.0
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _cache_path() -> str:
|
|
"""Return the cache file path, preferring the ComfyUI user directory."""
|
|
base_dir: str
|
|
if folder_paths is not None:
|
|
try:
|
|
base_dir = folder_paths.get_user_directory()
|
|
except Exception:
|
|
base_dir = ROOT_DIR
|
|
else:
|
|
base_dir = ROOT_DIR
|
|
|
|
cache_dir = os.path.join(base_dir, "nd_super_nodes")
|
|
os.makedirs(cache_dir, exist_ok=True)
|
|
return os.path.join(cache_dir, CACHE_FILENAME)
|
|
|
|
|
|
def _read_json(path: str) -> Optional[Dict[str, Any]]:
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _write_json(path: str, payload: Dict[str, Any]) -> None:
|
|
try:
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
except Exception as exc:
|
|
print(f"ND Super Nodes: Failed to write cache '{path}': {exc}")
|
|
|
|
|
|
def get_local_version() -> Dict[str, Any]:
|
|
"""Read local version information from the packaged manifest."""
|
|
data = _read_json(VERSION_FILE) or {}
|
|
version = str(data.get("version") or "0.0.0")
|
|
built_at = str(data.get("builtAt") or "")
|
|
if not built_at:
|
|
built_at = _now().isoformat()
|
|
return {
|
|
"version": version,
|
|
"builtAt": built_at,
|
|
}
|
|
|
|
|
|
def _parse_version(value: Optional[str]) -> tuple:
|
|
if not value:
|
|
return tuple()
|
|
# Extract numeric segments; fall back to zero when absent
|
|
parts = [int(part) for part in re.findall(r"\d+", value)]
|
|
return tuple(parts)
|
|
|
|
|
|
async def _fetch_latest_release() -> Optional[Dict[str, Any]]:
|
|
headers = {
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": USER_AGENT,
|
|
}
|
|
|
|
if aiohttp is not None:
|
|
timeout = aiohttp.ClientTimeout(total=12)
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
|
|
async with session.get(GITHUB_RELEASE_URL) as response:
|
|
if response.status != 200:
|
|
text = await response.text()
|
|
print(
|
|
"ND Super Nodes: GitHub release check failed",
|
|
response.status,
|
|
text[:200],
|
|
)
|
|
return None
|
|
return await response.json()
|
|
except Exception as exc:
|
|
print(f"ND Super Nodes: GitHub release lookup error: {exc}")
|
|
return None
|
|
|
|
def _fetch_sync() -> Optional[Dict[str, Any]]:
|
|
request = urllib.request.Request(GITHUB_RELEASE_URL, headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=12) as resp: # type: ignore[arg-type]
|
|
status = getattr(resp, "status", None) or getattr(resp, "code", None)
|
|
if status != 200:
|
|
snippet = resp.read(200).decode("utf-8", "ignore")
|
|
print("ND Super Nodes: GitHub release check failed", status, snippet)
|
|
return None
|
|
raw = resp.read().decode("utf-8", "ignore")
|
|
return json.loads(raw)
|
|
except urllib.error.URLError as exc:
|
|
print(f"ND Super Nodes: GitHub release lookup error: {exc}")
|
|
return None
|
|
|
|
return await asyncio.to_thread(_fetch_sync)
|
|
|
|
|
|
def _compose_status(
|
|
local_version: Dict[str, Any],
|
|
release_payload: Optional[Dict[str, Any]],
|
|
checked_at: datetime,
|
|
) -> Dict[str, Any]:
|
|
current_version = local_version.get("version", "0.0.0")
|
|
latest_tag = (release_payload or {}).get("tag_name")
|
|
latest_version = str(latest_tag or "").lstrip("v")
|
|
release_url = (release_payload or {}).get("html_url")
|
|
published_at = (release_payload or {}).get("published_at")
|
|
|
|
current_tuple = _parse_version(str(current_version))
|
|
latest_tuple = _parse_version(latest_version)
|
|
has_update = bool(latest_tuple and latest_tuple > current_tuple)
|
|
|
|
return {
|
|
"checkedAt": checked_at.isoformat(),
|
|
"localVersion": local_version,
|
|
"latestVersion": latest_version,
|
|
"publishedAt": published_at,
|
|
"releaseUrl": release_url,
|
|
"hasUpdate": has_update,
|
|
"raw": {
|
|
"tag": latest_tag,
|
|
"name": (release_payload or {}).get("name"),
|
|
"notes": (release_payload or {}).get("body"),
|
|
"prerelease": (release_payload or {}).get("prerelease", False),
|
|
},
|
|
}
|
|
|
|
|
|
def _load_cached_status() -> Optional[Dict[str, Any]]:
|
|
cache_path = _cache_path()
|
|
data = _read_json(cache_path)
|
|
if not data:
|
|
return None
|
|
return data
|
|
|
|
|
|
def _persist_status(status: Dict[str, Any]) -> None:
|
|
_write_json(_cache_path(), status)
|
|
|
|
|
|
async def get_update_status(force: bool = False) -> Dict[str, Any]:
|
|
"""Return cached or freshly fetched update status."""
|
|
global _cache_data, _cache_timestamp
|
|
|
|
now = time.time()
|
|
async with _cache_lock:
|
|
if (
|
|
not force
|
|
and _cache_data is not None
|
|
and now - _cache_timestamp < CHECK_INTERVAL_SECONDS
|
|
):
|
|
return _cache_data
|
|
|
|
cached = None if force else _load_cached_status()
|
|
if cached:
|
|
# Invalidate cache if local version has changed (e.g., after update)
|
|
current_version = get_local_version().get("version", "0.0.0")
|
|
if cached.get('localVersion', {}).get('version') != current_version:
|
|
cached = None
|
|
else:
|
|
try:
|
|
checked_at = datetime.fromisoformat(cached.get("checkedAt"))
|
|
age = (_now() - checked_at).total_seconds()
|
|
if age < CHECK_INTERVAL_SECONDS:
|
|
_cache_data = cached
|
|
_cache_timestamp = now
|
|
return cached
|
|
except Exception:
|
|
pass
|
|
|
|
release_payload = await _fetch_latest_release()
|
|
status = _compose_status(get_local_version(), release_payload, _now())
|
|
_cache_data = status
|
|
_cache_timestamp = now
|
|
_persist_status(status)
|
|
return status
|
|
|
|
|
|
async def clear_update_cache() -> None:
|
|
"""Remove cached update data (useful for tests/debug)."""
|
|
global _cache_data, _cache_timestamp
|
|
async with _cache_lock:
|
|
_cache_data = None
|
|
_cache_timestamp = 0.0
|
|
cache_file = _cache_path()
|
|
try:
|
|
if os.path.exists(cache_file):
|
|
os.remove(cache_file)
|
|
except Exception as exc:
|
|
print(f"ND Super Nodes: Failed to clear update cache: {exc}")
|
|
|