From aa91d1410c35aea871db42b533d49b7788704666 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sat, 27 Jun 2026 23:39:31 +0200 Subject: [PATCH] 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 --- __init__.py | 40 ++ core/downloader.py | 583 +++++++++++++++++- core/settings.py | 8 + web/resolver/actions/queue_methods.js | 78 ++- .../actions/resolve_download_methods.js | 268 +++++++- web/resolver/search/search_panel.js | 5 +- web/resolver/views/options_methods.js | 111 ++++ 7 files changed, 1047 insertions(+), 46 deletions(-) diff --git a/__init__.py b/__init__.py index d349e36..eaa5a77 100644 --- a/__init__.py +++ b/__init__.py @@ -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): diff --git a/core/downloader.py b/core/downloader.py index 3de8f78..8775df9 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -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) diff --git a/core/settings.py b/core/settings.py index 188bc34..c377a3e 100644 --- a/core/settings.py +++ b/core/settings.py @@ -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 ) diff --git a/web/resolver/actions/queue_methods.js b/web/resolver/actions/queue_methods.js index 89a3b9e..022b04e 100644 --- a/web/resolver/actions/queue_methods.js +++ b/web/resolver/actions/queue_methods.js @@ -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 = '
'; 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 += `
Type${this.escapeHtml(category)}
`; } + if (backend) { + html += `
Backend${this.escapeHtml(backend)}
`; + } if (targetLabel) { html += `
Folder${this.escapeHtml(targetLabel)}
`; } @@ -1181,7 +1230,14 @@ export const queueMethods = { html += `
`; html += `
${this.escapeHtml(sizeText)}${this.escapeHtml(progressMeta)}
`; html += `
`; - html += `
`; + html += `
`; + if (isAria2 && isPaused) { + html += ``; + } else if (isAria2 && status === 'downloading') { + html += ``; + } + html += ``; + html += `
`; html += ``; } html += ''; diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js index 3b9ab18..3fde399 100644 --- a/web/resolver/actions/resolve_download_methods.js +++ b/web/resolver/actions/resolve_download_methods.js @@ -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' : ''}` : 'Connecting...'; + 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 + ? `` + : ``; + actionsHtml = `${toggleButton}`; + } 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'); + }); }, /** diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js index a84fae2..c7d4a84 100644 --- a/web/resolver/search/search_panel.js +++ b/web/resolver/search/search_panel.js @@ -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 ? `` - : ''; + : ''); return `
diff --git a/web/resolver/views/options_methods.js b/web/resolver/views/options_methods.js index 7f6c743..075df84 100644 --- a/web/resolver/views/options_methods.js +++ b/web/resolver/views/options_methods.js @@ -37,6 +37,24 @@ export const optionsMethods = { .map(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 => ``) + .join(''); + }; const renderTemplatePlaceholderBadges = () => ['{base_model}', '{author}', '{first_tag}', '{model_name}', '{version_name}'] .map(placeholder => `${this.escapeHtml(placeholder)}`) .join(''); @@ -211,6 +229,33 @@ export const optionsMethods = {

Download Sources

+
+
+
+
Download Engine
+ ? +
+
+
+ Download backend +
+ +
+
+
+ aria2c path ? +
+ +
+
+ + Setup guide +
+
Python downloader is used by default. aria2 requires aria2c installed or a configured executable path.
+
+
${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)