Files
ComfyUI AtticandClaude Sonnet 4.6 c7a5ad8785 fix: re-detect drives on every root browse request
DRIVE_LIST was populated once at import time, so swapping an SD card
had no effect until ComfyUI restarted. Now _detect_drives() is called
on each root-level browse (triggered by opening or refreshing the file
browser), keeping the drive list and security whitelist current.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:17:35 -04:00

300 lines
11 KiB
Python

"""
api.py — aiohttp route handlers for the External LoRA Loader custom node.
Registers seven routes on ComfyUI's aiohttp server:
POST /external_lora/list_files — list .safetensors/.ckpt files in a directory
POST /external_lora/clear_cache — flush the in-memory LoRA tensor cache
GET /external_lora/cache_stats — return current and max cache usage in MB
GET /external_lora/loras_folder — return ComfyUI's configured loras folder
GET /external_lora/drives — return detected drive/mount-point list
POST /external_lora/browse — browse directories and files interactively
GET /external_lora/metadata — return file stats and embedded safetensors metadata
"""
import os
import glob
import json as _json
import platform
import struct
from datetime import datetime
import psutil
from aiohttp import web
from .cache import LORA_CACHE
# ---------------------------------------------------------------------------
# Drive detection (runs at import time)
# ---------------------------------------------------------------------------
def _detect_drives() -> list:
system = platform.system()
if system == "Windows":
try:
return [p.mountpoint for p in psutil.disk_partitions()]
except Exception:
return ["C:/"]
elif system == "Darwin":
return ["/"] + sorted(p for p in glob.glob("/Volumes/*") if os.path.isdir(p))
else: # Linux and others
return ["/"] + sorted(p for p in glob.glob("/media/*") if os.path.isdir(p)) + sorted(p for p in glob.glob("/mnt/*") if os.path.isdir(p))
DRIVE_LIST: list = _detect_drives()
# ---------------------------------------------------------------------------
# Route handlers
# ---------------------------------------------------------------------------
def _probe_dir(path: str) -> tuple:
"""Return (accessible: bool, reason: str | None) by attempting to list the directory."""
try:
os.listdir(path)
return True, None
except PermissionError:
return False, "Access denied"
except OSError as e:
return False, e.strerror if e.strerror else "Unavailable"
def _is_within_drive(full_path: str, drive_list: list) -> bool:
"""Return True if full_path is at or below one of the known drive roots."""
fp = os.path.normpath(full_path)
for d in drive_list:
root = os.path.normpath(d)
root_prefix = root if root.endswith(os.sep) else root + os.sep
if fp == root or fp.startswith(root_prefix):
return True
return False
_SUPPORTED_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
async def _list_files_handler(request: web.Request) -> web.Response:
"""POST /external_lora/list_files
Request JSON: {"drive": "D:/", "path": "AI/Models/LoRA"}
Response JSON: {"files": ["none", ...], "error": null | "bad_request" | "forbidden" | "not_found"}
"""
try:
body = await request.json()
except Exception:
return web.json_response({"files": [], "error": "bad_request"}, status=400)
drive = body.get("drive", "")
path = body.get("path", "")
full_path = os.path.normpath(os.path.join(drive, path))
if not _is_within_drive(full_path, DRIVE_LIST):
return web.json_response({"files": [], "error": "forbidden"}, status=403)
if not os.path.isdir(full_path):
return web.json_response({"files": [], "error": "not_found"})
entries = []
try:
for name in os.listdir(full_path):
# Skip Unix dot-prefixed hidden files
if name.startswith("."):
continue
ext = os.path.splitext(name)[1].lower()
if ext in _SUPPORTED_EXTENSIONS:
entries.append(name)
except OSError:
return web.json_response({"files": [], "error": "not_found"})
entries.sort()
entries.insert(0, "none")
return web.json_response({"files": entries, "error": None})
async def _clear_cache_handler(request: web.Request) -> web.Response:
"""POST /external_lora/clear_cache
Response JSON: {"status": "cleared", "freed_mb": <float>}
"""
freed_mb = LORA_CACHE.clear()
return web.json_response({"status": "cleared", "freed_mb": round(freed_mb, 2)})
async def _cache_stats_handler(request: web.Request) -> web.Response:
"""GET /external_lora/cache_stats
Response JSON: {"used_mb": float, "max_mb": float}
"""
return web.json_response({
"used_mb": round(LORA_CACHE.current_mb(), 2),
"max_mb": LORA_CACHE.max_mb,
})
async def _loras_folder_handler(request: web.Request) -> web.Response:
"""GET /external_lora/loras_folder
Response JSON: {"drive": "C:\\", "sub_path": "path/to/loras"}
Returns empty strings if the ComfyUI loras path cannot be determined.
"""
try:
import folder_paths
dirs = folder_paths.get_folder_paths("loras")
if dirs:
full = os.path.normpath(dirs[0])
drive, tail = os.path.splitdrive(full)
if drive: # Windows — e.g. "C:"
drive = drive + os.sep
sub = tail.lstrip(os.sep).replace(os.sep, "/")
else: # Unix — no drive component
drive = "/"
sub = tail.lstrip("/")
return web.json_response({"drive": drive, "sub_path": sub})
except Exception:
pass
return web.json_response({"drive": "", "sub_path": ""})
async def _drives_handler(request: web.Request) -> web.Response:
"""GET /external_lora/drives
Response JSON: {"drives": [...]}
"""
return web.json_response({"drives": DRIVE_LIST})
async def _browse_handler(request: web.Request) -> web.Response:
"""POST /external_lora/browse
Request JSON: {"drive": "D:/", "path": "AI/Models"}
Response JSON: {"dirs": [...], "files": [...], "error": null | "not_found" | "forbidden" | "bad_request"}
Special sentinel: if drive is empty or missing, returns the drive list as dirs.
"""
try:
body = await request.json()
except Exception:
return web.json_response({"dirs": [], "files": [], "error": "bad_request"}, status=400)
drive = body.get("drive", "")
path = body.get("path", "")
# Resolve extension filter for this request
extensions_raw = body.get("extensions", None)
if extensions_raw == ["*"]:
ext_filter = None # accept every non-hidden file
elif extensions_raw:
ext_filter = {e.lower() for e in extensions_raw if e.startswith(".")}
else:
ext_filter = _SUPPORTED_EXTENSIONS
# Root sentinel: no drive → re-detect drives then return probed list
if not drive:
global DRIVE_LIST
DRIVE_LIST = _detect_drives()
entries = []
for d in DRIVE_LIST:
accessible, reason = _probe_dir(d)
entries.append({"name": d, "accessible": accessible, "reason": reason})
return web.json_response({"dirs": entries, "files": [], "error": None})
full_path = os.path.normpath(os.path.join(drive, path))
# Security: must be within a known drive
if not _is_within_drive(full_path, DRIVE_LIST):
return web.json_response({"dirs": [], "files": [], "error": "forbidden"}, status=403)
if not os.path.isdir(full_path):
return web.json_response({"dirs": [], "files": [], "error": "not_found"})
dirs, files = [], []
try:
for name in os.listdir(full_path):
if name.startswith("."):
continue
entry = os.path.join(full_path, name)
if os.path.isdir(entry):
accessible, reason = _probe_dir(entry)
dirs.append({"name": name, "accessible": accessible, "reason": reason})
elif ext_filter is None or os.path.splitext(name)[1].lower() in ext_filter:
files.append(name)
except OSError:
return web.json_response({"dirs": [], "files": [], "error": "not_found"})
dirs.sort(key=lambda d: d["name"])
files.sort()
return web.json_response({"dirs": dirs, "files": files, "error": None})
async def _metadata_handler(request: web.Request) -> web.Response:
"""GET /external_lora/metadata?drive=<drive>&path=<relative_path>
Returns file system stats and, for .safetensors files, the embedded
__metadata__ dict parsed from the safetensors header.
Response JSON:
{ "file_size_mb": float, "modified": "YYYY-MM-DDTHH:MM:SS",
"metadata": dict | null, "error": str | null }
"""
drive = request.rel_url.query.get("drive", "")
path = request.rel_url.query.get("path", "")
if not drive and not path:
return web.json_response({"file_size_mb": None, "modified": None,
"metadata": None, "error": "missing path"})
full_path = os.path.normpath(os.path.join(drive, path))
if not _is_within_drive(full_path, DRIVE_LIST):
return web.json_response({"file_size_mb": None, "modified": None,
"metadata": None, "error": "forbidden"})
if not os.path.isfile(full_path):
return web.json_response({"file_size_mb": None, "modified": None,
"metadata": None, "error": "not_found"})
stat = os.stat(full_path)
size_mb = round(stat.st_size / (1024 * 1024), 2)
modified = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%dT%H:%M:%S")
metadata = None
if full_path.lower().endswith(".safetensors"):
try:
with open(full_path, "rb") as f:
raw = f.read(8)
if len(raw) == 8:
header_len = struct.unpack_from("<Q", raw)[0]
# Sanity-check: reject absurdly large headers (>64 MB)
if header_len <= 64 * 1024 * 1024:
header = _json.loads(f.read(header_len))
metadata = header.get("__metadata__", {})
except Exception:
pass
return web.json_response({
"file_size_mb": size_mb,
"modified": modified,
"metadata": metadata,
"error": None,
})
# ---------------------------------------------------------------------------
# Route registration
# ---------------------------------------------------------------------------
def register_routes(routes) -> None:
"""Wire handlers onto ComfyUI's aiohttp router.
Call as: register_routes(PromptServer.instance.routes)
"""
routes.post("/external_lora/list_files")(_list_files_handler)
routes.post("/external_lora/clear_cache")(_clear_cache_handler)
routes.get("/external_lora/cache_stats")(_cache_stats_handler)
routes.get("/external_lora/loras_folder")(_loras_folder_handler)
routes.get("/external_lora/drives")(_drives_handler)
routes.post("/external_lora/browse")(_browse_handler)
routes.get("/external_lora/metadata")(_metadata_handler)