feat: add aria2 download backend with pause/resume support

Integrates aria2c as an alternative download backend alongside the built-in
Python downloader. Adds settings for selecting the backend and configuring
the aria2c executable path, plus a status check button in the options panel.

Backend changes:
- core/downloader.py: aria2 daemon lifecycle, JSON-RPC client, pause/resume
  state machine, and transfer tracking
- core/settings.py: download_backend normalization and aria2c_path setting
- __init__.py: new API routes for pause, resume, and aria2 status checks

Frontend changes:
- options_methods.js: backend selector, aria2c path input, check button,
  and local storage persistence
- resolve_download_methods.js: pause/resume handlers, pending status
  transitions, and progress rendering for aria2 downloads
- queue_methods.js: queue panel pause/resume buttons, backend badge, and
  debounced panel updates
- search_panel.js: actionsHtml support for composite action buttons
This commit is contained in:
Dariusz L
2026-06-27 23:39:31 +02:00
parent e151f9b4c1
commit aa91d1410c
7 changed files with 1047 additions and 46 deletions
+40
View File
@@ -131,6 +131,9 @@ class ModelResolverExtension:
get_progress,
get_all_progress,
cancel_download,
pause_download,
resume_download,
get_aria2_status,
get_download_directory,
get_metadata_sidecar_path,
normalize_download_category,
@@ -3460,6 +3463,7 @@ class ModelResolverExtension:
"category": category,
"path": target_path,
"directory": target_directory,
"download_backend": settings.get("download_backend", "python"),
}
)
@@ -3492,6 +3496,42 @@ class ModelResolverExtension:
cancel_download(download_id)
return web.json_response({"success": True})
@routes.post("/model_resolver/pause/{download_id}")
@json_api_endpoint("pause", return_success_on_error=True)
async def pause_download_route(request):
"""Pause an aria2 download."""
download_id = request.match_info["download_id"]
result = pause_download(download_id)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
@routes.post("/model_resolver/resume/{download_id}")
@json_api_endpoint("resume", return_success_on_error=True)
async def resume_download_route(request):
"""Resume an aria2 download."""
download_id = request.match_info["download_id"]
result = resume_download(download_id)
status = 200 if result.get("success") else 400
return web.json_response(result, status=status)
@routes.get("/model_resolver/aria2/status")
@json_api_endpoint("aria2 status")
async def aria2_status_route(request):
"""Report aria2 availability using saved settings."""
settings = await asyncio.to_thread(load_resolver_settings)
return web.json_response(await asyncio.to_thread(get_aria2_status, settings))
@routes.post("/model_resolver/aria2/status")
@json_api_endpoint("aria2 status POST")
async def aria2_status_check_route(request):
"""Report aria2 availability using an optional unsaved aria2c path."""
payload = await request.json()
settings = await asyncio.to_thread(load_resolver_settings)
if isinstance(payload, dict) and "aria2c_path" in payload:
settings = dict(settings)
settings["aria2c_path"] = payload.get("aria2c_path", "")
return web.json_response(await asyncio.to_thread(get_aria2_status, settings))
@routes.get("/model_resolver/directories")
@json_api_endpoint("directories")
async def get_directories(request):
+582 -1
View File
@@ -7,6 +7,10 @@ Handles downloading models from various sources with progress tracking.
import os
import json
import hashlib
import secrets
import shutil
import socket
import subprocess
import threading
import time
import requests
@@ -35,13 +39,21 @@ except ImportError:
download_progress: Dict[str, Dict[str, Any]] = {}
download_lock = threading.Lock()
cancelled_downloads: set = set()
aria2_lock = threading.RLock()
aria2_process: Optional[subprocess.Popen] = None
aria2_rpc_url = ""
aria2_rpc_secret = ""
aria2_transfers: Dict[str, Dict[str, str]] = {}
aria2_action_locks: Dict[str, threading.Lock] = {}
aria2_desired_states: Dict[str, Dict[str, Any]] = {}
# Speed calculation settings
SPEED_HISTORY_SIZE = 5 # Number of samples for smoothing
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for faster downloads
CLI_LOG_INTERVAL = 5 # Log progress to CLI every N seconds
ARIA2_RPC_TIMEOUT = (2, 5) # local JSON-RPC should respond quickly
from .settings import CATEGORY_MAP, normalize_download_category
from .settings import CATEGORY_MAP, load_settings, normalize_download_backend, normalize_download_category
SENSITIVE_METADATA_KEYS = {
"authorization",
@@ -664,6 +676,429 @@ def generate_download_id() -> str:
return str(uuid.uuid4())[:8]
class Aria2Error(RuntimeError):
"""Raised when the aria2 backend cannot start or process a request."""
def _download_backend_from_settings(settings: Optional[Dict[str, Any]] = None) -> str:
active_settings = settings if isinstance(settings, dict) else load_settings()
return normalize_download_backend(active_settings.get("download_backend"))
def _try_certifi_ca_path() -> str:
try:
import certifi # type: ignore
path = certifi.where()
return path if path and os.path.isfile(path) else ""
except Exception:
return ""
def _resolve_aria2c_executable(settings: Optional[Dict[str, Any]] = None) -> str:
active_settings = settings if isinstance(settings, dict) else load_settings()
configured = str(active_settings.get("aria2c_path") or "").strip()
candidate = os.path.expandvars(os.path.expanduser(configured or "aria2c"))
resolved = shutil.which(candidate)
if resolved:
return resolved
if configured and os.path.isfile(candidate):
return candidate
raise Aria2Error(
"aria2c executable was not found. Install aria2 or configure aria2c_path."
)
def get_aria2_status(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
active_settings = settings if isinstance(settings, dict) else load_settings()
configured_path = str(active_settings.get("aria2c_path") or "").strip()
try:
resolved_path = _resolve_aria2c_executable(active_settings)
available = True
error = ""
except Exception as exc:
resolved_path = ""
available = False
error = str(exc)
with aria2_lock:
running = aria2_process is not None and aria2_process.poll() is None
return {
"backend": _download_backend_from_settings(active_settings),
"configured_path": configured_path,
"resolved_path": resolved_path,
"available": available,
"running": running,
"error": error,
}
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
sock.listen(1)
return int(sock.getsockname()[1])
def _aria2_rpc(method: str, params: Optional[List[Any]] = None) -> Any:
if not aria2_rpc_url:
raise Aria2Error("aria2 RPC endpoint is not initialized")
payload = {
"jsonrpc": "2.0",
"id": secrets.token_hex(8),
"method": method,
"params": [f"token:{aria2_rpc_secret}", *(params or [])],
}
response = requests.post(aria2_rpc_url, json=payload, timeout=ARIA2_RPC_TIMEOUT)
text = response.text
try:
body = response.json()
except ValueError as exc:
raise Aria2Error(
f"aria2 RPC returned non-JSON response ({response.status_code}): {text[:300]}"
) from exc
if "error" in body:
error = body.get("error") or {}
message = error.get("message") if isinstance(error, dict) else str(error)
raise Aria2Error(message or f"aria2 RPC {method} failed")
if response.status_code != 200:
raise Aria2Error(
f"aria2 RPC {method} returned HTTP {response.status_code}: {text[:300]}"
)
return body.get("result")
def _aria2_ping() -> bool:
try:
result = _aria2_rpc("aria2.getVersion", [])
return isinstance(result, dict)
except Exception:
return False
def _ensure_aria2_daemon(settings: Optional[Dict[str, Any]] = None) -> None:
global aria2_process, aria2_rpc_url, aria2_rpc_secret
active_settings = settings if isinstance(settings, dict) else load_settings()
with aria2_lock:
if aria2_process is not None and aria2_process.poll() is None and _aria2_ping():
return
if aria2_process is not None and aria2_process.poll() is None:
try:
aria2_process.terminate()
except Exception:
pass
aria2_process = None
executable = _resolve_aria2c_executable(active_settings)
port = _find_free_port()
aria2_rpc_secret = secrets.token_hex(16)
aria2_rpc_url = f"http://127.0.0.1:{port}/jsonrpc"
command = [
executable,
"--enable-rpc=true",
"--rpc-listen-all=false",
f"--rpc-listen-port={port}",
f"--rpc-secret={aria2_rpc_secret}",
"--check-certificate=true",
"--allow-overwrite=true",
"--auto-file-renaming=false",
"--file-allocation=none",
"--max-concurrent-downloads=5",
"--continue=true",
"--daemon=false",
"--quiet=true",
f"--stop-with-process={os.getpid()}",
]
ca_cert = _try_certifi_ca_path()
if ca_cert:
command.insert(5, f"--ca-certificate={ca_cert}")
creationflags = 0
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
log_info(f"Starting aria2 RPC daemon from {executable}")
aria2_process = subprocess.Popen(
command,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
creationflags=creationflags,
)
start_time = time.time()
last_error = ""
while time.time() - start_time < 10:
if aria2_process.poll() is not None:
stderr = ""
try:
stderr = (aria2_process.stderr.read() if aria2_process.stderr else b"").decode(
"utf-8",
errors="replace",
)
except Exception:
stderr = ""
raise Aria2Error(
f"aria2 RPC process exited early with code {aria2_process.returncode}: {stderr.strip()}"
)
try:
if _aria2_ping():
return
except Exception as exc:
last_error = str(exc)
time.sleep(0.2)
raise Aria2Error(
f"Timed out waiting for aria2 RPC to become ready{': ' + last_error if last_error else ''}"
)
def _aria2_tell_status(gid: str) -> Dict[str, Any]:
keys = [
"gid",
"status",
"totalLength",
"completedLength",
"downloadSpeed",
"errorMessage",
"files",
]
result = _aria2_rpc("aria2.tellStatus", [gid, keys])
return result if isinstance(result, dict) else {}
def _parse_aria2_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _resolve_aria2_completed_path(status: Dict[str, Any], default_path: str) -> str:
files = status.get("files")
if isinstance(files, list) and files:
first = files[0]
if isinstance(first, dict):
candidate = first.get("path")
if isinstance(candidate, str) and candidate:
return candidate
return default_path
def _delete_partial_download_files(dest_path: str) -> None:
for path in (dest_path, f"{dest_path}.aria2"):
try:
if path and os.path.exists(path):
os.remove(path)
except Exception as exc:
log_warn(f"Could not delete incomplete download file {path}: {exc}")
def download_file_with_aria2(
url: str,
dest_path: str,
download_id: str,
headers: Optional[Dict[str, str]] = None,
metadata: Optional[Dict[str, Any]] = None,
category: str = "",
) -> Dict[str, Any]:
"""Download a file with an aria2c JSON-RPC process."""
settings = load_settings()
result = {
"success": False,
"download_id": download_id,
"path": dest_path,
"error": None,
"size": 0,
}
start_time = time.time()
filename = os.path.basename(dest_path)
with download_lock:
download_progress[download_id] = {
"status": "starting",
"progress": 0,
"total_size": 0,
"downloaded": 0,
"filename": filename,
"path": dest_path,
"directory": os.path.dirname(dest_path),
"url": url,
"error": None,
"speed": 0,
"start_time": start_time,
"download_backend": "aria2",
}
try:
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
_ensure_aria2_daemon(settings)
options: Dict[str, Any] = {
"dir": os.path.dirname(dest_path),
"out": filename,
"continue": "true",
"max-connection-per-server": "4",
"split": "4",
"min-split-size": "1M",
"allow-overwrite": "true",
"auto-file-renaming": "false",
"file-allocation": "none",
}
if headers:
options["header"] = [f"{key}: {value}" for key, value in headers.items()]
gid = _aria2_rpc("aria2.addUri", [[url], options])
if not isinstance(gid, str) or not gid:
raise Aria2Error("aria2 did not return a download gid")
with aria2_lock:
aria2_transfers[download_id] = {"gid": gid, "path": dest_path}
with download_lock:
download_progress[download_id]["aria2_gid"] = gid
download_progress[download_id]["status"] = "downloading"
log_info(f"Starting aria2 download: {filename}")
last_cli_log = start_time
while True:
if download_id in cancelled_downloads:
try:
_aria2_rpc("aria2.forceRemove", [gid])
except Exception:
pass
with download_lock:
if download_id in download_progress:
download_progress[download_id]["status"] = "cancelled"
download_progress[download_id]["speed"] = 0
_delete_partial_download_files(dest_path)
cancelled_downloads.discard(download_id)
result["error"] = "Download cancelled"
return result
status = _aria2_tell_status(gid)
state = str(status.get("status") or "")
total_size = _parse_aria2_int(status.get("totalLength"))
downloaded = _parse_aria2_int(status.get("completedLength"))
speed = _parse_aria2_int(status.get("downloadSpeed"))
progress = int((downloaded / total_size) * 100) if total_size > 0 else 0
mapped_status = {
"active": "downloading",
"waiting": "downloading",
"paused": "paused",
"complete": "completed",
"error": "error",
"removed": "cancelled",
}.get(state, state or "downloading")
with download_lock:
if download_id in download_progress:
download_progress[download_id].update(
{
"status": mapped_status,
"progress": max(0, min(progress, 100)),
"total_size": total_size,
"downloaded": downloaded,
"speed": 0 if mapped_status in {"paused", "completed"} else speed,
"download_backend": "aria2",
"aria2_gid": gid,
}
)
now = time.time()
if now - last_cli_log >= CLI_LOG_INTERVAL and mapped_status == "downloading":
last_cli_log = now
total_str = format_bytes(total_size) if total_size else "?"
log_info(
f"aria2 progress: {format_bytes(downloaded)} / {total_str} ({progress}%) - {format_bytes(speed)}/s"
)
if state == "complete":
completed_path = _resolve_aria2_completed_path(status, dest_path)
size = os.path.getsize(completed_path) if os.path.exists(completed_path) else downloaded
metadata_path = write_lora_manager_metadata(
completed_path,
metadata or {},
category,
url,
)
with download_lock:
download_progress[download_id].update(
{
"status": "completed",
"progress": 100,
"downloaded": size,
"total_size": total_size or size,
"speed": 0,
"path": completed_path,
"directory": os.path.dirname(completed_path),
}
)
if metadata_path:
download_progress[download_id]["metadata_path"] = metadata_path
result.update(
{
"success": True,
"path": completed_path,
"size": size,
"metadata_path": metadata_path,
}
)
elapsed = time.time() - start_time
avg_speed = size / elapsed if elapsed > 0 else 0
log_info(f"✓ aria2 download complete: {filename}")
log_info(
f"Size: {format_bytes(size)}, Time: {elapsed:.1f}s, Avg speed: {format_bytes(int(avg_speed))}/s"
)
return result
if state == "error":
error_msg = status.get("errorMessage") or "aria2 download failed"
with download_lock:
download_progress[download_id]["status"] = "error"
download_progress[download_id]["error"] = error_msg
result["error"] = error_msg
return result
if state == "removed":
with download_lock:
download_progress[download_id]["status"] = "cancelled"
download_progress[download_id]["speed"] = 0
_delete_partial_download_files(dest_path)
cancelled_downloads.discard(download_id)
result["error"] = "Download cancelled"
return result
time.sleep(0.5)
except Exception as exc:
error_msg = str(exc)
with download_lock:
if download_id in download_progress:
download_progress[download_id]["status"] = "error"
download_progress[download_id]["error"] = error_msg
download_progress[download_id]["speed"] = 0
result["error"] = error_msg
log_error(f"✗ aria2 download failed: {filename}")
log_error(f"Error: {error_msg}")
return result
finally:
with aria2_lock:
aria2_transfers.pop(download_id, None)
aria2_action_locks.pop(download_id, None)
aria2_desired_states.pop(download_id, None)
def download_file(
url: str,
dest_path: str,
@@ -692,6 +1127,16 @@ def download_file(
"""
global download_progress, cancelled_downloads
if _download_backend_from_settings() == "aria2":
return download_file_with_aria2(
url,
dest_path,
download_id,
headers=headers,
metadata=metadata,
category=category,
)
# Use default 1MB chunk size if not specified
if chunk_size is None:
chunk_size = CHUNK_SIZE
@@ -724,6 +1169,7 @@ def download_file(
"error": None,
"speed": 0, # bytes per second
"start_time": start_time,
"download_backend": "python",
}
try:
@@ -1103,12 +1549,145 @@ def get_all_progress() -> Dict[str, Dict[str, Any]]:
return {k: v.copy() for k, v in download_progress.items()}
def _force_remove_aria2_transfer(download_id: str, gid: str) -> None:
try:
_aria2_rpc("aria2.forceRemove", [gid])
except Exception as exc:
log_warn(f"Could not cancel aria2 download {download_id}: {exc}")
def _get_aria2_action_lock(download_id: str) -> threading.Lock:
with aria2_lock:
lock = aria2_action_locks.get(download_id)
if lock is None:
lock = threading.Lock()
aria2_action_locks[download_id] = lock
return lock
def _set_download_progress_status(download_id: str, status: str, **updates: Any) -> None:
with download_lock:
if download_id in download_progress:
download_progress[download_id]["status"] = status
download_progress[download_id].update(updates)
def _aria2_action_error_is_ok(status: str, message: str) -> bool:
lowered = str(message or "").lower()
if status == "paused":
return "already paused" in lowered or "is paused" in lowered
if status == "downloading":
return "not paused" in lowered or "has not been paused" in lowered
return False
def _run_aria2_desired_state_worker(download_id: str) -> None:
while True:
with aria2_lock:
desired = dict(aria2_desired_states.get(download_id) or {})
if not desired or download_id in cancelled_downloads:
with aria2_lock:
state = aria2_desired_states.get(download_id)
if state:
state["running"] = False
return
desired_status = str(desired.get("status") or "")
desired_seq = int(desired.get("seq") or 0)
transfer = aria2_transfers.get(download_id)
gid = transfer.get("gid") if isinstance(transfer, dict) else ""
if not gid:
with aria2_lock:
aria2_desired_states.pop(download_id, None)
return
method = "aria2.forcePause" if desired_status == "paused" else "aria2.unpause"
try:
with _get_aria2_action_lock(download_id):
_aria2_rpc(method, [gid])
_set_download_progress_status(
download_id,
desired_status,
speed=0 if desired_status == "paused" else download_progress.get(download_id, {}).get("speed", 0),
)
except Exception as exc:
if _aria2_action_error_is_ok(desired_status, str(exc)):
_set_download_progress_status(download_id, desired_status, speed=0 if desired_status == "paused" else download_progress.get(download_id, {}).get("speed", 0))
else:
log_warn(f"aria2 {desired_status} action failed for {download_id}: {exc}")
with aria2_lock:
latest = aria2_desired_states.get(download_id)
if not latest:
return
if int(latest.get("seq") or 0) == desired_seq:
aria2_desired_states.pop(download_id, None)
return
def _queue_aria2_desired_state(download_id: str, status: str) -> Dict[str, Any]:
transfer = aria2_transfers.get(download_id)
if not transfer or not transfer.get("gid"):
return {"success": False, "error": "Download action is not available yet"}
start_worker = False
with aria2_lock:
previous = aria2_desired_states.get(download_id) or {}
seq = int(previous.get("seq") or 0) + 1
running = bool(previous.get("running"))
aria2_desired_states[download_id] = {
"status": status,
"seq": seq,
"running": True,
}
start_worker = not running
_set_download_progress_status(
download_id,
status,
speed=0 if status == "paused" else download_progress.get(download_id, {}).get("speed", 0),
)
if start_worker:
threading.Thread(
target=_run_aria2_desired_state_worker,
args=(download_id,),
daemon=True,
).start()
return {"success": True, "message": "Download paused" if status == "paused" else "Download resumed"}
def cancel_download(download_id: str) -> bool:
"""Cancel a download in progress."""
cancelled_downloads.add(download_id)
with aria2_lock:
aria2_desired_states.pop(download_id, None)
_set_download_progress_status(download_id, "cancelling", speed=0)
transfer = aria2_transfers.get(download_id)
if transfer and transfer.get("gid"):
threading.Thread(
target=_force_remove_aria2_transfer,
args=(download_id, transfer["gid"]),
daemon=True,
).start()
return True
def pause_download(download_id: str) -> Dict[str, Any]:
"""Pause an aria2 download. Built-in Python downloads cannot be paused."""
if download_id in cancelled_downloads:
return {"success": False, "error": "Download is being cancelled"}
return _queue_aria2_desired_state(download_id, "paused")
def resume_download(download_id: str) -> Dict[str, Any]:
"""Resume a paused aria2 download."""
if download_id in cancelled_downloads:
return {"success": False, "error": "Download is being cancelled"}
return _queue_aria2_desired_state(download_id, "downloading")
def clear_completed_downloads():
"""Clear completed/failed downloads from progress tracking."""
with download_lock:
@@ -1158,6 +1737,7 @@ def start_background_download(
"error": None,
"speed": 0,
"start_time": time.time(),
"download_backend": _download_backend_from_settings(),
}
def run_download():
@@ -1200,6 +1780,7 @@ def start_background_download(
"error": str(e),
"speed": 0,
"start_time": time.time(),
"download_backend": _download_backend_from_settings(),
}
thread = threading.Thread(target=run_download, daemon=True)
+8
View File
@@ -14,6 +14,7 @@ SETTINGS_FILE = Path(__file__).resolve().parents[1] / "model_resolver_settings.j
from .type_utils import to_bool, CATEGORY_MAP, normalize_download_category
DOWNLOAD_PATH_MODES = {"suggested", "template", "manual"}
DOWNLOAD_BACKENDS = {"python", "aria2"}
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
"loras": "{base_model}/{first_tag}",
@@ -155,8 +156,15 @@ def normalize_download_path_mode(value: Any, auto_fill_subfolder: Any = None) ->
return "suggested"
def normalize_download_backend(value: Any) -> str:
backend = str(value or "").strip().lower()
return backend if backend in DOWNLOAD_BACKENDS else "python"
def normalize_settings(settings: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
data = dict(settings or {})
data["download_backend"] = normalize_download_backend(data.get("download_backend"))
data["aria2c_path"] = str(data.get("aria2c_path") or "").strip()
data["auto_refresh_comfy_models_after_apply"] = bool_setting(
data.get("auto_refresh_comfy_models_after_apply"), True
)
+67 -11
View File
@@ -128,6 +128,10 @@ export const queueMethods = {
updateQueuePanel() {
if (!this.queueList || !this.queueHeader) return;
if (this._queuePanelUpdateTimer) {
clearTimeout(this._queuePanelUpdateTimer);
this._queuePanelUpdateTimer = null;
}
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
const downloads = this.getActiveQueuePanelDownloads();
const history = this.getDownloadHistory();
@@ -146,6 +150,15 @@ export const queueMethods = {
this.renderQueuedSelections(list);
},
requestQueuePanelUpdate(delayMs = 180) {
if (!this.queueList || !this.queueHeader) return;
if (this._queuePanelUpdateTimer) return;
this._queuePanelUpdateTimer = setTimeout(() => {
this._queuePanelUpdateTimer = null;
this.updateQueuePanel();
}, delayMs);
},
updateQueuePanelTabs(queueCount, downloadCount, activeTab) {
const queuedTab = this.queueQueuedTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="queued"]');
const downloadsTab = this.queueDownloadsTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="downloads"]');
@@ -1089,6 +1102,26 @@ export const queueMethods = {
},
wireDownloadsPanelControls() {
const bindInstantAction = (button, handler) => {
if (!button || button._hasListener) return;
button._hasListener = true;
const run = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
if (button.disabled) return;
if (event?.type === 'click' && button._handledPointerAction) {
button._handledPointerAction = false;
return;
}
if (event?.type === 'pointerdown') {
button._handledPointerAction = true;
}
handler();
};
button.addEventListener('pointerdown', run);
button.addEventListener('click', run);
};
this.queueList.querySelectorAll('.mr-downloads-subtab').forEach(button => {
button.addEventListener('click', () => {
this.setQueueDownloadsTab(button.dataset.downloadsTab || 'active');
@@ -1096,12 +1129,26 @@ export const queueMethods = {
});
this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => {
button.addEventListener('click', () => {
bindInstantAction(button, () => {
const downloadId = button.dataset.downloadId;
if (downloadId) this.cancelDownload(downloadId);
});
});
this.queueList.querySelectorAll('.mr-download-queue-pause').forEach(button => {
bindInstantAction(button, () => {
const downloadId = button.dataset.downloadId;
if (downloadId) this.pauseDownload(downloadId);
});
});
this.queueList.querySelectorAll('.mr-download-queue-resume').forEach(button => {
bindInstantAction(button, () => {
const downloadId = button.dataset.downloadId;
if (downloadId) this.resumeDownload(downloadId);
});
});
const clearHistoryButton = this.queueList.querySelector('.mr-download-history-clear');
if (clearHistoryButton) {
clearHistoryButton.addEventListener('click', () => this.clearDownloadHistory());
@@ -1119,13 +1166,7 @@ export const queueMethods = {
renderQueueDownloads(downloads) {
this.queueList.innerHTML = this.renderQueueDownloadsHtml(downloads);
this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => {
button.addEventListener('click', () => {
const downloadId = button.dataset.downloadId;
if (downloadId) this.cancelDownload(downloadId);
});
});
this.wireDownloadsPanelControls();
},
renderQueueDownloadsHtml(downloads) {
@@ -1135,7 +1176,9 @@ export const queueMethods = {
let html = '<div class="mr-queue-items mr-download-queue-items">';
for (const { downloadId, info } of downloads) {
const progress = info.lastProgress || {};
const progress = typeof this.applyPendingDownloadStatus === 'function'
? this.applyPendingDownloadStatus(info, info.lastProgress || {})
: (info.lastProgress || {});
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
const filename = progress.filename
|| info.filename
@@ -1148,9 +1191,12 @@ export const queueMethods = {
const total = progress.total_size ? this.formatBytes(progress.total_size) : '';
const progressMeta = this.formatDownloadProgressMeta?.(progress) || '';
const status = progress.status || info.lastStatus || 'starting';
const backend = String(progress.download_backend || info.downloadBackend || '').toLowerCase();
const isAria2 = backend === 'aria2';
const isPaused = status === 'paused';
const statusLabel = status === 'downloading'
? `${Math.round(percent)}%`
: (status === 'starting' ? 'Starting' : status.replace(/_/g, ' '));
: (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : status.replace(/_/g, ' ')));
const sizeText = total ? `${downloaded} / ${total}` : downloaded;
const targetPath = progress.directory || info.downloadDirectory || info.downloadPath || '';
const targetLabel = targetPath ? targetPath.split(/[\/\\]/).filter(Boolean).pop() || targetPath : '';
@@ -1174,6 +1220,9 @@ export const queueMethods = {
if (category) {
html += `<div class="mr-queue-item-meta"><span>Type</span><code>${this.escapeHtml(category)}</code></div>`;
}
if (backend) {
html += `<div class="mr-queue-item-meta"><span>Backend</span><code>${this.escapeHtml(backend)}</code></div>`;
}
if (targetLabel) {
html += `<div class="mr-queue-item-meta"><span>Folder</span><code data-tooltip="${this.escapeHtml(targetPath)}">${this.escapeHtml(targetLabel)}</code></div>`;
}
@@ -1181,7 +1230,14 @@ export const queueMethods = {
html += `<div class="mr-progress-bar"><div class="mr-progress-fill" style="width: ${percent}%;"></div></div>`;
html += `<div class="mr-progress-text"><span>${this.escapeHtml(sizeText)}</span><span>${this.escapeHtml(progressMeta)}</span></div>`;
html += `</div>`;
html += `<div class="mr-queue-item-actions"><button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-download-queue-cancel" data-download-id="${this.escapeHtml(downloadId)}">Cancel</button></div>`;
html += `<div class="mr-queue-item-actions">`;
if (isAria2 && isPaused) {
html += `<button type="button" class="mr-btn mr-btn-primary mr-btn-sm mr-download-queue-resume" data-download-id="${this.escapeHtml(downloadId)}">Resume</button>`;
} else if (isAria2 && status === 'downloading') {
html += `<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-download-queue-pause" data-download-id="${this.escapeHtml(downloadId)}">Pause</button>`;
}
html += `<button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-download-queue-cancel" data-download-id="${this.escapeHtml(downloadId)}">Cancel</button>`;
html += `</div>`;
html += `</div>`;
}
html += '</div>';
+236 -32
View File
@@ -749,7 +749,8 @@ export const resolveDownloadMethods = {
status,
filename: progress.filename || info.filename || '',
path: progress.path || info.downloadPath || '',
directory: progress.directory || info.downloadDirectory || ''
directory: progress.directory || info.downloadDirectory || '',
download_backend: progress.download_backend || info.downloadBackend || ''
},
status,
message: options.message || '',
@@ -811,12 +812,35 @@ export const resolveDownloadMethods = {
return { progressDiv, downloadBtn };
},
attachDownloadCancelHandler(progressDiv, downloadId) {
attachDownloadActionHandlers(progressDiv, downloadId) {
if (!progressDiv || !downloadId) return;
const bindInstantAction = (button, handler) => {
if (!button || button._hasListener) return;
button._hasListener = true;
const run = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
if (button.disabled) return;
if (event?.type === 'click' && button._handledPointerAction) {
button._handledPointerAction = false;
return;
}
if (event?.type === 'pointerdown') {
button._handledPointerAction = true;
}
handler();
};
button.addEventListener('pointerdown', run);
button.addEventListener('click', run);
};
progressDiv.querySelectorAll('.cancel-download-btn, .cancel-download-btn-pending').forEach(cancelBtn => {
if (cancelBtn._hasListener) return;
cancelBtn._hasListener = true;
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
bindInstantAction(cancelBtn, () => this.cancelDownload(downloadId));
});
progressDiv.querySelectorAll('.pause-download-btn').forEach(pauseBtn => {
bindInstantAction(pauseBtn, () => this.pauseDownload(downloadId));
});
progressDiv.querySelectorAll('.resume-download-btn').forEach(resumeBtn => {
bindInstantAction(resumeBtn, () => this.resumeDownload(downloadId));
});
},
@@ -832,6 +856,7 @@ export const resolveDownloadMethods = {
const terminalStatuses = new Set(['cancelling', 'cancelled', 'error', 'refresh_error', 'completed_checking', 'completed']);
const shouldRenderProgress = status === 'starting'
|| status === 'downloading'
|| status === 'paused'
|| (isActive && !terminalStatuses.has(status));
if (progressDiv) {
@@ -840,23 +865,44 @@ export const resolveDownloadMethods = {
if (shouldRenderProgress) {
const canCancel = Boolean(downloadId);
const backend = String(progress.download_backend || snapshot.downloadBackend || snapshot.download_backend || '').toLowerCase();
const isAria2 = backend === 'aria2';
const isPaused = status === 'paused';
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
const downloaded = this.formatBytes(progress.downloaded || 0);
const total = this.formatBytes(progress.total_size || 0);
const progressMeta = this.formatDownloadProgressMeta(progress);
const leftText = progress.total_size
? `${downloaded} / ${total} (${percent}%)`
? `${downloaded} / ${total} (${percent}%)${isPaused ? ' - Paused' : ''}`
: '<span class="mr-info-accent-text">Connecting...</span>';
let actionClass = canCancel ? 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm' : '';
let actionText = canCancel ? 'Cancel' : '';
let actionsHtml = '';
if (canCancel && isAria2 && isPaused) {
actionClass = 'resume-download-btn mr-btn mr-btn-primary mr-btn-sm';
actionText = 'Resume';
} else if (canCancel && isAria2 && status === 'downloading') {
actionClass = 'pause-download-btn mr-btn mr-btn-secondary mr-btn-sm';
actionText = 'Pause';
}
if (canCancel && isAria2 && (isPaused || status === 'downloading')) {
const safeDownloadId = this.escapeHtml(String(downloadId));
const toggleButton = isPaused
? `<button class="resume-download-btn mr-btn mr-btn-primary mr-btn-sm" data-download-id="${safeDownloadId}">Resume</button>`
: `<button class="pause-download-btn mr-btn mr-btn-secondary mr-btn-sm" data-download-id="${safeDownloadId}">Pause</button>`;
actionsHtml = `${toggleButton}<button class="cancel-download-btn mr-btn mr-btn-danger mr-btn-sm" data-download-id="${safeDownloadId}">Cancel</button>`;
}
progressDiv.innerHTML = this.renderProgressWithAction({
percent,
leftText,
rightText: progressMeta,
actionClass: canCancel ? 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm' : '',
actionText: canCancel ? 'Cancel' : '',
actionClass,
actionText,
actionDataAttr: canCancel ? `data-download-id="${downloadId}"` : '',
actionsHtml,
contextMenuModel: this.getDownloadFolderContext(progress, snapshot)
});
this.attachDownloadCancelHandler(progressDiv, downloadId);
this.attachDownloadActionHandlers(progressDiv, downloadId);
} else if (status === 'cancelled') {
progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning');
} else if (status === 'cancelling') {
@@ -896,13 +942,17 @@ export const resolveDownloadMethods = {
const percent = Number(progress.progress);
downloadBtn.disabled = true;
downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary');
const label = Number.isFinite(percent) && percent > 0 ? `Downloading ${Math.round(percent)}%` : 'Starting download...';
const label = status === 'paused'
? `Paused ${Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : ''}`.trim()
: (Number.isFinite(percent) && percent > 0 ? `Downloading ${Math.round(percent)}%` : 'Starting download...');
if (downloadBtn.classList.contains('search-download-btn')) {
downloadBtn.innerHTML = getSvgIcon('download');
downloadBtn.setAttribute('data-tooltip', label);
downloadBtn.setAttribute('aria-label', label);
} else {
downloadBtn.textContent = Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...';
downloadBtn.textContent = status === 'paused'
? 'Paused'
: (Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...');
}
} else if (status === 'cancelling') {
downloadBtn.disabled = true;
@@ -1255,7 +1305,8 @@ export const resolveDownloadMethods = {
workflowTabId,
workflowTabName,
workflowTabAriaControls,
workflowTabText
workflowTabText,
downloadBackend: data.download_backend || ''
};
const snapshot = this.rememberDownloadUiState(
@@ -1266,7 +1317,8 @@ export const resolveDownloadMethods = {
progress: 0,
filename,
path: data.path || '',
directory: data.directory || ''
directory: data.directory || '',
download_backend: data.download_backend || ''
},
{ isActive: true }
);
@@ -1359,6 +1411,59 @@ export const resolveDownloadMethods = {
});
},
setPendingDownloadStatus(info, status, durationMs = 8000) {
if (!info) return;
info.pendingDownloadStatus = status;
info.pendingDownloadStatusStartedAt = Date.now();
info.pendingDownloadStatusUntil = Date.now() + durationMs;
},
clearPendingDownloadStatus(info) {
if (!info) return;
delete info.pendingDownloadStatus;
delete info.pendingDownloadStatusStartedAt;
delete info.pendingDownloadStatusUntil;
},
applyPendingDownloadStatus(info, progress = {}) {
if (!info?.pendingDownloadStatus) return progress;
const desiredStatus = info.pendingDownloadStatus;
const currentStatus = progress.status || '';
const terminalStatuses = new Set(['completed', 'completed_checking', 'error', 'cancelled', 'cancelling']);
if (terminalStatuses.has(currentStatus)) {
this.clearPendingDownloadStatus(info);
return progress;
}
if (
currentStatus === desiredStatus &&
Date.now() - Number(info.pendingDownloadStatusStartedAt || 0) > 1200
) {
this.clearPendingDownloadStatus(info);
return progress;
}
if (Date.now() > Number(info.pendingDownloadStatusUntil || 0)) {
this.clearPendingDownloadStatus(info);
return progress;
}
const canHoldDesiredStatus = (
desiredStatus === 'downloading' && currentStatus === 'paused'
) || (
desiredStatus === 'paused' && (currentStatus === 'downloading' || currentStatus === 'starting')
);
if (!canHoldDesiredStatus) return progress;
return {
...progress,
backend_status: currentStatus,
status: desiredStatus,
speed: desiredStatus === 'paused' ? 0 : progress.speed
};
},
/**
* Poll download progress
*/
@@ -1367,18 +1472,23 @@ export const resolveDownloadMethods = {
if (!info) return;
try {
const progress = await this.fetchJson(`/model_resolver/progress/${downloadId}`, { silent: true }, 'Get download progress');
let progress = await this.fetchJson(`/model_resolver/progress/${downloadId}`, { silent: true }, 'Get download progress');
progress = this.applyPendingDownloadStatus(info, progress);
const snapshot = this.rememberDownloadUiState(downloadId, info, progress, { isActive: true });
const { progressDiv, downloadBtn } = this.resolveDownloadUiElements(info);
const { missing, category } = info;
const downloadFolderContext = this.getDownloadFolderContext(progress, info);
if (progress.status === 'downloading' || progress.status === 'starting') {
if (progress.status === 'downloading' || progress.status === 'starting' || progress.status === 'paused') {
this.renderDownloadSnapshot(downloadId, snapshot, { progressDiv, downloadBtn });
this.updateQueuePanel?.();
if (typeof this.requestQueuePanelUpdate === 'function') {
this.requestQueuePanelUpdate();
} else {
this.updateQueuePanel?.();
}
// Continue polling
setTimeout(() => this.pollDownloadProgress(downloadId), 1000);
setTimeout(() => this.pollDownloadProgress(downloadId), progress.status === 'paused' ? 1500 : 1000);
} else if (progress.status === 'completed') {
const alreadyExists = Boolean(progress.already_exists);
@@ -1485,33 +1595,127 @@ export const resolveDownloadMethods = {
/**
* Cancel an active download
*/
async cancelDownload(downloadId) {
try {
await this.fetchJson(`/model_resolver/cancel/${downloadId}`, {
method: 'POST'
}, 'Cancel download');
cancelDownload(downloadId) {
const info = this.activeDownloads[downloadId];
if (info) {
this.clearPendingDownloadStatus(info);
const snapshot = this.rememberDownloadUiState(
downloadId,
info,
{ ...(info.lastProgress || {}), status: 'cancelling' },
{
status: 'cancelling',
message: 'Cancelling download...',
type: 'info',
isActive: true
}
);
const elements = this.resolveDownloadUiElements(info);
this.renderDownloadSnapshot(downloadId, snapshot, elements);
this.updateQueuePanel?.();
}
const info = this.activeDownloads[downloadId];
if (info) {
this.fetchJson(`/model_resolver/cancel/${downloadId}`, {
method: 'POST'
}, 'Cancel download').catch((error) => {
console.error('Model Resolver: Cancel error:', error);
this.showNotification('Failed to cancel download', 'error');
});
},
pauseDownload(downloadId) {
const info = this.activeDownloads[downloadId];
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
if (info) {
this.setPendingDownloadStatus(info, 'paused');
const snapshot = this.rememberDownloadUiState(
downloadId,
info,
{ ...(info.lastProgress || {}), status: 'paused', speed: 0 },
{
status: 'paused',
message: 'Download paused.',
type: 'info',
isActive: true
}
);
const elements = this.resolveDownloadUiElements(info);
this.renderDownloadSnapshot(downloadId, snapshot, elements);
this.updateQueuePanel?.();
}
this.fetchJson(`/model_resolver/pause/${downloadId}`, {
method: 'POST'
}, 'Pause download').then(() => {
this.showNotification('Download paused', 'info');
}).catch((error) => {
console.error('Model Resolver: Pause error:', error);
if (info && previousProgress) {
this.clearPendingDownloadStatus(info);
const snapshot = this.rememberDownloadUiState(
downloadId,
info,
info.lastProgress || { status: 'cancelling', progress: 0 },
previousProgress,
{
status: 'cancelling',
message: 'Cancelling download...',
type: 'info',
status: previousProgress.status || 'downloading',
type: 'error',
isActive: true
}
);
this.renderDownloadSnapshot(downloadId, snapshot);
const elements = this.resolveDownloadUiElements(info);
this.renderDownloadSnapshot(downloadId, snapshot, elements);
this.updateQueuePanel?.();
}
this.showNotification(error.message || 'Failed to pause download', 'error');
});
},
} catch (error) {
console.error('Model Resolver: Cancel error:', error);
this.showNotification('Failed to cancel download', 'error');
resumeDownload(downloadId) {
const info = this.activeDownloads[downloadId];
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
if (info) {
this.setPendingDownloadStatus(info, 'downloading');
const snapshot = this.rememberDownloadUiState(
downloadId,
info,
{ ...(info.lastProgress || {}), status: 'downloading' },
{
status: 'downloading',
message: 'Download resumed.',
type: 'info',
isActive: true
}
);
const elements = this.resolveDownloadUiElements(info);
this.renderDownloadSnapshot(downloadId, snapshot, elements);
this.updateQueuePanel?.();
window.setTimeout(() => this.pollDownloadProgress(downloadId), 250);
}
this.fetchJson(`/model_resolver/resume/${downloadId}`, {
method: 'POST'
}, 'Resume download').then(() => {
this.showNotification('Download resumed', 'success');
}).catch((error) => {
console.error('Model Resolver: Resume error:', error);
if (info && previousProgress) {
this.clearPendingDownloadStatus(info);
const snapshot = this.rememberDownloadUiState(
downloadId,
info,
previousProgress,
{
status: previousProgress.status || 'paused',
type: 'error',
isActive: true
}
);
const elements = this.resolveDownloadUiElements(info);
this.renderDownloadSnapshot(downloadId, snapshot, elements);
this.updateQueuePanel?.();
}
this.showNotification(error.message || 'Failed to resume download', 'error');
});
},
/**
+3 -2
View File
@@ -1926,6 +1926,7 @@ export const searchPanelMethods = {
actionClass = '',
actionText = '',
actionDataAttr = '',
actionsHtml = '',
contextMenuModel = null,
contextMenuTooltip = 'Right-click to open download folder'
} = {}) {
@@ -1935,9 +1936,9 @@ export const searchPanelMethods = {
const containerClass = contextMenuAttrs
? 'mr-progress-container mr-download-folder-context'
: 'mr-progress-container';
const actionHtml = actionClass || actionText
const actionHtml = actionsHtml || (actionClass || actionText
? `<button class="${actionClass}"${actionAttr}>${actionText}</button>`
: '';
: '');
return `
<div class="${containerClass}"${contextMenuAttrs}>
<div class="mr-progress-row">
+111
View File
@@ -37,6 +37,24 @@ export const optionsMethods = {
.map(option => `<option value="${option.value}" ${option.value === selected ? 'selected' : ''} title="${this.escapeHtml(option.tooltip)}">${this.escapeHtml(option.label)}</option>`)
.join('');
};
const renderDownloadBackendOptions = (selectedValue) => {
const selected = this.normalizeDownloadBackend(selectedValue);
const options = [
{
value: 'python',
label: 'Python (built-in)',
tooltip: 'Uses Model Resolver built-in downloader. This remains the default and requires no external tools.'
},
{
value: 'aria2',
label: 'aria2 (experimental)',
tooltip: 'Uses an external aria2c process for large downloads, pause/resume, and segmented transfers.'
}
];
return options
.map(option => `<option value="${option.value}" ${option.value === selected ? 'selected' : ''} title="${this.escapeHtml(option.tooltip)}">${this.escapeHtml(option.label)}</option>`)
.join('');
};
const renderTemplatePlaceholderBadges = () => ['{base_model}', '{author}', '{first_tag}', '{model_name}', '{version_name}']
.map(placeholder => `<span class="mr-options-placeholder-badge">${this.escapeHtml(placeholder)}</span>`)
.join('');
@@ -211,6 +229,33 @@ export const optionsMethods = {
<h4 class="mr-options-section-title">Download Sources</h4>
</div>
<div class="mr-options-grid">
<div class="mr-options-panel">
<div class="mr-options-stack">
<div class="mr-options-subsection-head">
<h5 class="mr-options-subsection-title">Download Engine</h5>
<span class="mr-tooltip-badge" data-tooltip="Choose how model files are downloaded. aria2 requires the external aria2c executable and is intended for large downloads.">?</span>
</div>
<div class="mr-options-number-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">Download backend</span>
</div>
<select id="mr-options-download-backend" class="mr-options-input">
${renderDownloadBackendOptions(tokens.download_backend)}
</select>
</div>
<div class="mr-options-number-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">aria2c path <span class="mr-tooltip-badge" data-tooltip="Optional full path to aria2c/aria2c.exe. Leave empty to use aria2c from your system PATH.">?</span></span>
</div>
<input id="mr-options-aria2c-path" class="mr-options-input" type="text" value="${this.escapeHtml(tokens.aria2c_path || '')}" placeholder="Leave empty to use aria2c from PATH">
</div>
<div class="mr-options-db-actions">
<button id="mr-options-aria2-check" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Check aria2</button>
<a class="mr-options-inline-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener noreferrer">Setup guide</a>
</div>
<div id="mr-options-aria2-status" class="mr-options-db-message">Python downloader is used by default. aria2 requires aria2c installed or a configured executable path.</div>
</div>
</div>
<div class="mr-options-panel">
<div class="mr-options-toggle-list">
${sourceDefaultsRows}
@@ -619,6 +664,10 @@ export const optionsMethods = {
const autoFillBaseModelInput = this.contentElement.querySelector('#mr-options-auto-fill-base-model');
const autoFillSubfolderInput = this.contentElement.querySelector('#mr-options-auto-fill-subfolder');
const autoRefreshComfyModelsInput = this.contentElement.querySelector('#mr-options-auto-refresh-comfy-models');
const downloadBackendInput = this.contentElement.querySelector('#mr-options-download-backend');
const aria2cPathInput = this.contentElement.querySelector('#mr-options-aria2c-path');
const aria2CheckBtn = this.contentElement.querySelector('#mr-options-aria2-check');
const aria2StatusEl = this.contentElement.querySelector('#mr-options-aria2-status');
const downloadPathModeInput = this.contentElement.querySelector('#mr-options-download-path-mode');
const defaultRootSelectInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-default-root'));
const templatePresetInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-template-preset'));
@@ -673,6 +722,8 @@ export const optionsMethods = {
autoFillBaseModelInput,
autoFillSubfolderInput,
autoRefreshComfyModelsInput,
downloadBackendInput,
aria2cPathInput,
downloadPathModeInput,
...defaultRootSelectInputs,
...templatePresetInputs,
@@ -721,6 +772,40 @@ export const optionsMethods = {
if (mode) statusEl.classList.add(mode);
};
const setAria2Status = (text, mode = '') => {
if (!aria2StatusEl) return;
aria2StatusEl.textContent = text;
aria2StatusEl.classList.remove('is-valid', 'is-invalid', 'is-pending');
if (mode) aria2StatusEl.classList.add(mode);
};
const checkAria2Status = async ({ useUnsavedPath = true } = {}) => {
if (aria2CheckBtn) aria2CheckBtn.disabled = true;
setAria2Status('Checking aria2...', 'is-pending');
try {
const data = await this.fetchJson('/model_resolver/aria2/status', {
method: 'POST',
body: JSON.stringify({
aria2c_path: useUnsavedPath
? (aria2cPathInput?.value || '')
: (tokens.aria2c_path || '')
}),
silent: true
}, 'Check aria2');
if (data?.available) {
const path = data.resolved_path || 'aria2c from PATH';
const runningText = data.running ? ' Daemon is running.' : ' Daemon will start on first aria2 download.';
setAria2Status(`aria2 is available: ${path}.${runningText}`, 'is-valid');
} else {
setAria2Status(data?.error || 'aria2c was not found. Install aria2 or configure the aria2c path.', 'is-invalid');
}
} catch (error) {
setAria2Status(error.message || 'aria2 check failed.', 'is-invalid');
} finally {
if (aria2CheckBtn) aria2CheckBtn.disabled = false;
}
};
const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => {
const value = (input?.value || '').trim();
if (!value) {
@@ -1494,6 +1579,16 @@ export const optionsMethods = {
});
}
if (aria2CheckBtn) {
aria2CheckBtn.addEventListener('click', () => {
checkAria2Status({ useUnsavedPath: true });
});
}
if (tokens.download_backend === 'aria2') {
checkAria2Status({ useUnsavedPath: false });
}
if (civitaiSessionCheckBtn) {
civitaiSessionCheckBtn.addEventListener('click', () => {
checkCredential({
@@ -1576,6 +1671,7 @@ export const optionsMethods = {
);
});
const baseModelPathMappings = collectBaseModelPathMappings();
const downloadBackend = this.normalizeDownloadBackend(downloadBackendInput?.value || tokens.download_backend);
const downloadPathMode = this.normalizeDownloadPathMode(downloadPathModeInput?.value || tokens.download_path_mode);
const defaultRootSettings = {};
defaultRootSelectInputs.forEach((select) => {
@@ -1598,6 +1694,8 @@ export const optionsMethods = {
auto_fill_base_model: Boolean(autoFillBaseModelInput?.checked),
auto_fill_subfolder: Boolean(autoFillSubfolderInput?.checked),
auto_refresh_comfy_models_after_apply: Boolean(autoRefreshComfyModelsInput?.checked),
download_backend: downloadBackend,
aria2c_path: aria2cPathInput?.value?.trim() || '',
download_path_mode: downloadPathMode,
download_path_templates: downloadPathTemplates,
base_model_path_mappings: baseModelPathMappings,
@@ -1623,6 +1721,8 @@ export const optionsMethods = {
localStorage.setItem('ModelResolver.autoFillBaseModel', newSettings.auto_fill_base_model ? 'true' : 'false');
localStorage.setItem('ModelResolver.autoFillSubfolder', newSettings.auto_fill_subfolder ? 'true' : 'false');
localStorage.setItem('ModelResolver.autoRefreshComfyModelsAfterApply', newSettings.auto_refresh_comfy_models_after_apply ? 'true' : 'false');
localStorage.setItem('ModelResolver.downloadBackend', newSettings.download_backend);
localStorage.setItem('ModelResolver.aria2cPath', newSettings.aria2c_path);
localStorage.setItem('ModelResolver.downloadPathMode', newSettings.download_path_mode);
localStorage.setItem('ModelResolver.downloadPathTemplates', JSON.stringify(newSettings.download_path_templates));
localStorage.setItem('ModelResolver.baseModelPathMappings', JSON.stringify(newSettings.base_model_path_mappings));
@@ -1666,6 +1766,11 @@ export const optionsMethods = {
},
normalizeDownloadBackend(value = '') {
const backend = String(value || '').trim().toLowerCase();
return backend === 'aria2' ? 'aria2' : 'python';
},
getStoredTokens() {
const civitaiCandidateLimitRaw = parseInt(localStorage.getItem('ModelResolver.civitaiCandidateLimit') || '5', 10);
const civitai_candidate_limit = Number.isFinite(civitaiCandidateLimitRaw)
@@ -1700,6 +1805,8 @@ export const optionsMethods = {
backend_log_level: storedBackendLogLevel || 'DEBUG',
civitai_candidate_limit,
search_source_enabled,
download_backend: this.normalizeDownloadBackend(localStorage.getItem('ModelResolver.downloadBackend') || 'python'),
aria2c_path: localStorage.getItem('ModelResolver.aria2cPath') || '',
download_path_mode: this.getDownloadPathMode(),
download_path_templates: this.getDownloadPathTemplates(),
base_model_path_mappings: this.getBaseModelPathMappings(),
@@ -1755,6 +1862,10 @@ export const optionsMethods = {
localStorage.setItem('ModelResolver.autoFillSubfolder', data.auto_fill_subfolder ? 'true' : 'false');
if (data.auto_refresh_comfy_models_after_apply !== undefined)
localStorage.setItem('ModelResolver.autoRefreshComfyModelsAfterApply', data.auto_refresh_comfy_models_after_apply ? 'true' : 'false');
if (data.download_backend !== undefined)
localStorage.setItem('ModelResolver.downloadBackend', this.normalizeDownloadBackend(data.download_backend));
if (data.aria2c_path !== undefined)
localStorage.setItem('ModelResolver.aria2cPath', String(data.aria2c_path || ''));
if (data.download_path_mode !== undefined)
localStorage.setItem('ModelResolver.downloadPathMode', this.normalizeDownloadPathMode(data.download_path_mode));
if (data.download_path_templates !== undefined)