From 54f861a02e89428c649316fc184aa958d2b6d287 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 30 Jun 2026 00:03:08 +0200 Subject: [PATCH] feat: add aria2 daemon start/stop control and install migration - Add /aria2/start and /aria2/stop API endpoints for explicit daemon lifecycle control - Track aria2 daemon state (managed/running/version/active_transfers) in get_aria2_status - Implement idle auto-stop timer (5 minutes) with aria2_auto_stop_daemon setting - Add start_aria2_daemon() and stop_aria2_daemon() helpers in core/downloader.py - Migrate legacy release-* install directories to normalized versioned paths - Normalize aria2 version strings (strip release-/v prefixes) in installer metadata - Update aria2 archive extraction to extract-to-temp then copy into canonical dir - Add frontend daemon controls: status display, stop button, auto-stop toggle --- __init__.py | 27 +++ core/aria2_installer.py | 109 +++++++++++- core/downloader.py | 173 ++++++++++++++++++- core/settings.py | 3 + web/css/resolver-main.css | 28 +++ web/resolver/views/options_methods.js | 239 +++++++++++++++++++++++++- 6 files changed, 562 insertions(+), 17 deletions(-) diff --git a/__init__.py b/__init__.py index fb9f172..f558731 100644 --- a/__init__.py +++ b/__init__.py @@ -134,6 +134,8 @@ class ModelResolverExtension: pause_download, resume_download, get_aria2_status, + start_aria2_daemon, + stop_aria2_daemon, get_download_directory, get_metadata_sidecar_path, normalize_download_category, @@ -3540,6 +3542,31 @@ class ModelResolverExtension: settings["aria2c_path"] = payload.get("aria2c_path", "") return web.json_response(await asyncio.to_thread(get_aria2_status, settings)) + @routes.post("/model_resolver/aria2/start") + @json_api_endpoint("aria2 start", return_success_on_error=True) + async def aria2_start_route(request): + """Start the aria2 daemon without starting a download.""" + try: + payload = await request.json() + except Exception: + payload = {} + 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", "") + result = await asyncio.to_thread(start_aria2_daemon, settings) + status = 200 if result.get("success") else 400 + return web.json_response(result, status=status) + + @routes.get("/model_resolver/aria2/stop") + @routes.post("/model_resolver/aria2/stop") + @json_api_endpoint("aria2 stop", return_success_on_error=True) + async def aria2_stop_route(request): + """Stop the aria2 daemon started by Model Resolver.""" + result = await asyncio.to_thread(stop_aria2_daemon) + status = 200 if result.get("success") else 400 + return web.json_response(result, status=status) + @routes.post("/model_resolver/aria2/install") @json_api_endpoint("aria2 install") async def aria2_install_route(request): diff --git a/core/aria2_installer.py b/core/aria2_installer.py index 59782ed..593cfa5 100644 --- a/core/aria2_installer.py +++ b/core/aria2_installer.py @@ -32,6 +32,20 @@ def _safe_name(value: Any) -> str: return safe.strip("._") or "aria2" +def _normalize_version(value: Any) -> str: + text = str(value or "").strip() + lower = text.lower() + if lower.startswith("release-"): + text = text[len("release-"):] + elif lower.startswith("v") and len(text) > 1 and text[1].isdigit(): + text = text[1:] + return _safe_name(text or "latest") + + +def _canonical_install_dir(version: Any) -> Path: + return ARIA2_INSTALL_ROOT / _normalize_version(version) + + def _machine_bits(machine: str) -> str: machine = str(machine or "").lower() if machine in {"amd64", "x86_64", "x64", "arm64", "aarch64"}: @@ -241,7 +255,19 @@ def _find_existing_install(exe_name: str) -> Optional[Path]: candidates.append(path) if not candidates: return None - candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True) + + def candidate_key(path: Path) -> tuple: + try: + relative_depth = len(path.relative_to(ARIA2_INSTALL_ROOT).parts) + except ValueError: + relative_depth = 999 + try: + modified = path.stat().st_mtime + except OSError: + modified = 0 + return (1 if relative_depth <= 2 else 0, modified) + + candidates.sort(key=candidate_key, reverse=True) return candidates[0] @@ -253,12 +279,69 @@ def _existing_install_metadata(executable: Path) -> Dict[str, str]: parts = () return { - "version": parts[0] if parts else "", - "asset": parts[1] if len(parts) > 1 else "", + "version": _normalize_version(parts[0] if parts else ""), + "asset": parts[1] if len(parts) > 2 else "", "install_dir": str(executable.parent), } +def _copy_directory_contents(source: Path, destination: Path) -> None: + source_abs = source.resolve() + destination_abs = destination.resolve() + if source_abs == destination_abs: + return + _assert_within_install_root(destination_abs) + destination_abs.mkdir(parents=True, exist_ok=True) + for child in source_abs.iterdir(): + target = destination_abs / child.name + _assert_within_install_root(target) + if child.is_dir(): + if target.exists() and not target.is_dir(): + target.unlink() + shutil.copytree(child, target, dirs_exist_ok=True) + else: + if target.exists() and target.is_dir(): + shutil.rmtree(target) + shutil.copy2(child, target) + + +def _cleanup_legacy_install_dirs() -> None: + if not ARIA2_INSTALL_ROOT.exists(): + return + for child in ARIA2_INSTALL_ROOT.iterdir(): + if not child.is_dir() or not child.name.lower().startswith("release-"): + continue + _assert_within_install_root(child) + shutil.rmtree(child, ignore_errors=True) + + +def _migrate_existing_install(executable: Path, exe_name: str) -> Path: + metadata = _existing_install_metadata(executable) + version = metadata.get("version") or "latest" + canonical_dir = _canonical_install_dir(version) + canonical_executable = canonical_dir / exe_name + try: + if executable.resolve() == canonical_executable.resolve(): + return executable + except OSError: + pass + + _assert_within_install_root(canonical_dir) + if canonical_dir.exists(): + _assert_within_install_root(canonical_dir) + shutil.rmtree(canonical_dir) + canonical_dir.mkdir(parents=True, exist_ok=True) + _copy_directory_contents(executable.parent, canonical_dir) + + migrated_executable = _find_executable(canonical_dir, exe_name) + if not migrated_executable: + raise Aria2InstallError( + f"Could not migrate existing aria2 install to {canonical_dir}." + ) + _cleanup_legacy_install_dirs() + return migrated_executable + + def _download_file(url: str, destination: Path) -> int: total = 0 try: @@ -295,6 +378,8 @@ def install_aria2_engine(force: bool = False) -> Dict[str, Any]: if not force: existing_executable = _find_existing_install(tokens["exe_name"]) if existing_executable and existing_executable.exists(): + existing_executable = _migrate_existing_install(existing_executable, tokens["exe_name"]) + _cleanup_legacy_install_dirs() _chmod_executable(existing_executable) existing_metadata = _existing_install_metadata(existing_executable) return { @@ -310,12 +395,13 @@ def install_aria2_engine(force: bool = False) -> Dict[str, Any]: release = _fetch_latest_release() asset = _select_release_asset(release, tokens) tag_name = str(release.get("tag_name") or "latest").strip() or "latest" + version = _normalize_version(tag_name) asset_name = _asset_name(asset) asset_url = _asset_url(asset) if not asset_url: raise Aria2InstallError(f"aria2 release asset {asset_name or ''} has no download URL.") - install_dir = ARIA2_INSTALL_ROOT / _safe_name(tag_name) / _safe_name(Path(asset_name).stem) + install_dir = _canonical_install_dir(version) _assert_within_install_root(install_dir) existing_executable = _find_executable(install_dir, tokens["exe_name"]) if existing_executable and existing_executable.exists() and not force: @@ -324,7 +410,7 @@ def install_aria2_engine(force: bool = False) -> Dict[str, Any]: "success": True, "already_installed": True, "aria2c_path": str(existing_executable), - "version": tag_name, + "version": version, "asset": asset_name, "platform": tokens["label"], "install_dir": str(install_dir), @@ -336,13 +422,21 @@ def install_aria2_engine(force: bool = False) -> Dict[str, Any]: with tempfile.TemporaryDirectory(prefix="model-resolver-aria2-") as tmp_dir_text: tmp_dir = Path(tmp_dir_text) archive_path = tmp_dir / asset_name + extract_dir = tmp_dir / "extract" downloaded_bytes = _download_file(asset_url, archive_path) + extract_dir.mkdir(parents=True, exist_ok=True) + _extract_archive(archive_path, extract_dir) + extracted_executable = _find_executable(extract_dir, tokens["exe_name"]) + if not extracted_executable: + raise Aria2InstallError( + f"Installed aria2 archive did not contain {tokens['exe_name']}." + ) if install_dir.exists(): _assert_within_install_root(install_dir) shutil.rmtree(install_dir) install_dir.mkdir(parents=True, exist_ok=True) - _extract_archive(archive_path, install_dir) + _copy_directory_contents(extracted_executable.parent, install_dir) executable = _find_executable(install_dir, tokens["exe_name"]) if not executable: @@ -350,12 +444,13 @@ def install_aria2_engine(force: bool = False) -> Dict[str, Any]: f"Installed aria2 archive did not contain {tokens['exe_name']}." ) _chmod_executable(executable) + _cleanup_legacy_install_dirs() return { "success": True, "already_installed": False, "aria2c_path": str(executable), - "version": tag_name, + "version": version, "asset": asset_name, "platform": tokens["label"], "install_dir": str(install_dir), diff --git a/core/downloader.py b/core/downloader.py index 88d6187..93f033a 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -46,12 +46,15 @@ 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]] = {} +aria2_idle_timer: Optional[threading.Timer] = None +aria2_process_started_by_resolver = False # 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 +ARIA2_IDLE_STOP_SECONDS = 5 * 60 DOWNLOAD_USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " @@ -805,27 +808,76 @@ def _resolve_aria2c_executable(settings: Optional[Dict[str, Any]] = None) -> str ) +def _read_aria2_version(executable: str) -> str: + if not executable: + return "" + try: + kwargs: Dict[str, Any] = {} + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) + result = subprocess.run( + [executable, "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=5, + check=False, + **kwargs, + ) + except Exception: + return "" + + first_line = "" + for line in str(result.stdout or "").splitlines(): + text = line.strip() + if text: + first_line = text + break + if not first_line: + return "" + + for token in first_line.replace(",", " ").split(): + if token and token[0].isdigit(): + return token + return first_line + + def get_aria2_status(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + global aria2_process, aria2_process_started_by_resolver + 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 + version = _read_aria2_version(resolved_path) error = "" except Exception as exc: resolved_path = "" available = False + version = "" error = str(exc) with aria2_lock: running = aria2_process is not None and aria2_process.poll() is None + if not running and aria2_process is not None: + aria2_process = None + aria2_process_started_by_resolver = False + managed = bool(running and aria2_process_started_by_resolver) + active_transfers = len(aria2_transfers) return { "backend": _download_backend_from_settings(active_settings), "configured_path": configured_path, "resolved_path": resolved_path, "available": available, + "version": version, "running": running, + "managed": managed, + "can_stop": bool(managed and active_transfers == 0), + "active_transfers": active_transfers, + "auto_stop_enabled": bool(active_settings.get("aria2_auto_stop_daemon", True)), + "idle_stop_seconds": ARIA2_IDLE_STOP_SECONDS, "error": error, } @@ -877,11 +929,127 @@ def _aria2_ping() -> bool: return False +def _cancel_aria2_idle_timer_locked() -> None: + global aria2_idle_timer + if aria2_idle_timer is not None: + aria2_idle_timer.cancel() + aria2_idle_timer = None + + +def _aria2_has_active_transfers_locked() -> bool: + return bool(aria2_transfers) + + +def stop_aria2_daemon(reason: str = "manual") -> Dict[str, Any]: + """Stop the aria2 RPC process started by Model Resolver.""" + global aria2_process, aria2_rpc_url, aria2_rpc_secret, aria2_process_started_by_resolver + + with aria2_lock: + _cancel_aria2_idle_timer_locked() + running = aria2_process is not None and aria2_process.poll() is None + if not running: + aria2_process = None + aria2_rpc_url = "" + aria2_rpc_secret = "" + aria2_process_started_by_resolver = False + return {"success": True, "stopped": False, "message": "aria2 daemon is not running"} + + if not aria2_process_started_by_resolver: + return { + "success": False, + "stopped": False, + "error": "This aria2 daemon was not started by Model Resolver.", + } + + if _aria2_has_active_transfers_locked(): + return { + "success": False, + "stopped": False, + "error": "aria2 daemon has active downloads.", + } + + process = aria2_process + try: + process.terminate() + process.wait(timeout=5) + except Exception: + try: + process.kill() + process.wait(timeout=2) + except Exception: + pass + + aria2_process = None + aria2_rpc_url = "" + aria2_rpc_secret = "" + aria2_process_started_by_resolver = False + + log_info(f"aria2 RPC daemon stopped ({reason})") + return {"success": True, "stopped": True, "message": "aria2 daemon stopped"} + + +def start_aria2_daemon(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Start the aria2 RPC process without creating a download.""" + active_settings = settings if isinstance(settings, dict) else load_settings() + try: + _ensure_aria2_daemon(active_settings) + status = get_aria2_status(active_settings) + return { + **status, + "success": True, + "started": bool(status.get("running")), + "message": "aria2 daemon is running", + } + except Exception as exc: + try: + status = get_aria2_status(active_settings) + except Exception: + status = {} + return { + **status, + "success": False, + "started": False, + "error": str(exc), + } + + +def _aria2_idle_stop_worker() -> None: + global aria2_idle_timer + + with aria2_lock: + aria2_idle_timer = None + + settings = load_settings() + if not settings.get("aria2_auto_stop_daemon", True): + return + with aria2_lock: + running = aria2_process is not None and aria2_process.poll() is None + if not running or not aria2_process_started_by_resolver or _aria2_has_active_transfers_locked(): + return + stop_aria2_daemon(reason="idle") + + +def _schedule_aria2_idle_stop() -> None: + global aria2_idle_timer + settings = load_settings() + if not settings.get("aria2_auto_stop_daemon", True): + return + with aria2_lock: + _cancel_aria2_idle_timer_locked() + running = aria2_process is not None and aria2_process.poll() is None + if not running or not aria2_process_started_by_resolver or _aria2_has_active_transfers_locked(): + return + aria2_idle_timer = threading.Timer(ARIA2_IDLE_STOP_SECONDS, _aria2_idle_stop_worker) + aria2_idle_timer.daemon = True + aria2_idle_timer.start() + + def _ensure_aria2_daemon(settings: Optional[Dict[str, Any]] = None) -> None: - global aria2_process, aria2_rpc_url, aria2_rpc_secret + global aria2_process, aria2_rpc_url, aria2_rpc_secret, aria2_process_started_by_resolver active_settings = settings if isinstance(settings, dict) else load_settings() with aria2_lock: + _cancel_aria2_idle_timer_locked() if aria2_process is not None and aria2_process.poll() is None and _aria2_ping(): return @@ -891,6 +1059,7 @@ def _ensure_aria2_daemon(settings: Optional[Dict[str, Any]] = None) -> None: except Exception: pass aria2_process = None + aria2_process_started_by_resolver = False executable = _resolve_aria2c_executable(active_settings) port = _find_free_port() @@ -928,6 +1097,7 @@ def _ensure_aria2_daemon(settings: Optional[Dict[str, Any]] = None) -> None: stderr=subprocess.PIPE, creationflags=creationflags, ) + aria2_process_started_by_resolver = True start_time = time.time() last_error = "" @@ -1210,6 +1380,7 @@ def download_file_with_aria2( aria2_transfers.pop(download_id, None) aria2_action_locks.pop(download_id, None) aria2_desired_states.pop(download_id, None) + _schedule_aria2_idle_stop() def download_file( diff --git a/core/settings.py b/core/settings.py index c377a3e..7af2abc 100644 --- a/core/settings.py +++ b/core/settings.py @@ -165,6 +165,9 @@ 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["aria2_auto_stop_daemon"] = bool_setting( + data.get("aria2_auto_stop_daemon"), True + ) data["auto_refresh_comfy_models_after_apply"] = bool_setting( data.get("auto_refresh_comfy_models_after_apply"), True ) diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index acee90b..9f26f99 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -6211,6 +6211,30 @@ a.mr-image-preview-resource-name:hover { text-align: right; } +.mr-options-db-row strong.is-pending { + color: var(--mr-text-secondary); +} + +.mr-options-db-row strong.is-valid { + color: var(--mr-color-success); +} + +.mr-options-db-row strong.is-invalid { + color: var(--mr-color-error-light); +} + +.mr-options-aria2-summary .mr-options-db-row { + align-items: flex-start; +} + +.mr-options-aria2-daemon-cell { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + .mr-options-db-message { margin-top: 10px; color: var(--mr-text-secondary); @@ -6740,6 +6764,10 @@ a.mr-image-preview-resource-name:hover { background: transparent; } +.mr-options-compact-toggle-row { + padding: 10px 0; +} + .mr-options-toggle-copy { display: grid; gap: 0; diff --git a/web/resolver/views/options_methods.js b/web/resolver/views/options_methods.js index 733d562..d472ab5 100644 --- a/web/resolver/views/options_methods.js +++ b/web/resolver/views/options_methods.js @@ -250,12 +250,38 @@ export const optionsMethods = { +
+
+ Backend + aria2 +
+
+ Status + Not checked +
+
+ Daemon + + + Not checked + +
+
+
Setup guide
-
aria2 requires aria2c installed or a configured executable path.
+ @@ -677,7 +703,12 @@ export const optionsMethods = { const aria2cPathInput = this.contentElement.querySelector('#mr-options-aria2c-path'); const aria2InstallBtn = this.contentElement.querySelector('#mr-options-aria2-install'); const aria2CheckBtn = this.contentElement.querySelector('#mr-options-aria2-check'); + const aria2StopBtn = this.contentElement.querySelector('#mr-options-aria2-stop'); const aria2StatusEl = this.contentElement.querySelector('#mr-options-aria2-status'); + const aria2BackendStateEl = this.contentElement.querySelector('#mr-options-aria2-backend-state'); + const aria2AvailabilityEl = this.contentElement.querySelector('#mr-options-aria2-availability'); + const aria2DaemonEl = this.contentElement.querySelector('#mr-options-aria2-daemon'); + const aria2AutoStopInput = this.contentElement.querySelector('#mr-options-aria2-auto-stop'); const aria2SettingEls = Array.from(this.contentElement.querySelectorAll('.mr-options-aria2-setting')); const downloadPathModeInput = this.contentElement.querySelector('#mr-options-download-path-mode'); const defaultRootSelectInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-default-root')); @@ -735,6 +766,7 @@ export const optionsMethods = { autoRefreshComfyModelsInput, downloadBackendInput, aria2cPathInput, + aria2AutoStopInput, downloadPathModeInput, ...defaultRootSelectInputs, ...templatePresetInputs, @@ -785,22 +817,90 @@ export const optionsMethods = { const setAria2Status = (text, mode = '') => { if (!aria2StatusEl) return; + aria2StatusEl.hidden = !text; aria2StatusEl.textContent = text; aria2StatusEl.classList.remove('is-valid', 'is-invalid', 'is-pending'); if (mode) aria2StatusEl.classList.add(mode); }; + const setAria2SummaryValue = (element, text, mode = '') => { + if (!element) return; + element.textContent = text; + element.classList.remove('is-valid', 'is-invalid', 'is-pending'); + if (mode) element.classList.add(mode); + }; + + const syncAria2DaemonButton = (data = {}) => { + if (!aria2StopBtn) return; + const available = data.available === true; + const running = Boolean(data.running); + aria2StopBtn.hidden = !available; + aria2StopBtn.dataset.action = running ? 'stop' : 'start'; + aria2StopBtn.textContent = running ? 'Stop' : 'Start'; + aria2StopBtn.disabled = running && data.can_stop === false; + aria2StopBtn.title = aria2StopBtn.disabled + ? 'Cannot stop while aria2 has active downloads' + : (running + ? 'Stop aria2 daemon started by Model Resolver' + : 'Start aria2 daemon now'); + }; + + const renderAria2Summary = (data = {}, state = 'idle') => { + const backend = this.normalizeDownloadBackend(data.backend || downloadBackendInput?.value || tokens.download_backend); + const version = String(data.version || '').trim(); + const backendLabel = backend === 'aria2' + ? `aria2${version ? ` ${version}` : ''}` + : 'Python'; + + setAria2SummaryValue(aria2BackendStateEl, backendLabel); + + if (state === 'checking') { + syncAria2DaemonButton({ available: false, running: false }); + setAria2SummaryValue(aria2AvailabilityEl, 'Checking...', 'is-pending'); + setAria2SummaryValue(aria2DaemonEl, 'Checking...', 'is-pending'); + return; + } + + if (state === 'installing') { + syncAria2DaemonButton({ available: false, running: false }); + setAria2SummaryValue(aria2AvailabilityEl, 'Installing...', 'is-pending'); + setAria2SummaryValue(aria2DaemonEl, 'Not started'); + return; + } + + if (data && typeof data.available === 'boolean') { + syncAria2DaemonButton(data); + setAria2SummaryValue( + aria2AvailabilityEl, + data.available ? 'Available' : 'Not found', + data.available ? 'is-valid' : 'is-invalid' + ); + setAria2SummaryValue( + aria2DaemonEl, + data.running ? 'Running' : 'Starts on first download', + data.running ? 'is-valid' : '' + ); + return; + } + + syncAria2DaemonButton({ available: false, running: false }); + setAria2SummaryValue(aria2AvailabilityEl, 'Not checked'); + setAria2SummaryValue(aria2DaemonEl, 'Not checked'); + }; + const syncAria2OptionsVisibility = () => { const backend = this.normalizeDownloadBackend(downloadBackendInput?.value || tokens.download_backend); const showAria2 = backend === 'aria2'; aria2SettingEls.forEach((element) => { element.hidden = !showAria2; }); + renderAria2Summary({ backend }); }; const checkAria2Status = async ({ useUnsavedPath = true } = {}) => { if (aria2CheckBtn) aria2CheckBtn.disabled = true; - setAria2Status('Checking aria2...', 'is-pending'); + setAria2Status(''); + renderAria2Summary({ backend: 'aria2' }, 'checking'); try { const data = await this.fetchJson('/model_resolver/aria2/status', { method: 'POST', @@ -811,15 +911,24 @@ export const optionsMethods = { }), silent: true }, 'Check aria2'); + renderAria2Summary({ + ...data, + backend: downloadBackendInput?.value || data.backend + }); 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'); + setAria2Status(''); } 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'); + renderAria2Summary({ + backend: 'aria2', + configured_path: aria2cPathInput?.value || '', + resolved_path: '', + available: false, + running: false + }); } finally { if (aria2CheckBtn) aria2CheckBtn.disabled = false; } @@ -828,7 +937,8 @@ export const optionsMethods = { const installAria2 = async () => { if (aria2InstallBtn) aria2InstallBtn.disabled = true; if (aria2CheckBtn) aria2CheckBtn.disabled = true; - setAria2Status('Downloading and installing aria2 from official GitHub releases...', 'is-pending'); + setAria2Status(''); + renderAria2Summary({ backend: 'aria2' }, 'installing'); try { const data = await this.fetchJson('/model_resolver/aria2/install', { method: 'POST', @@ -854,18 +964,104 @@ export const optionsMethods = { localStorage.setItem('ModelResolver.aria2cPath', data.aria2c_path); localStorage.setItem('ModelResolver.downloadBackend', 'aria2'); - const versionText = data.version ? ` ${data.version}` : ''; - const installedText = data.already_installed ? 'already installed' : 'installed'; - setAria2Status(`aria2${versionText} ${installedText}: ${data.aria2c_path}`, 'is-valid'); + setAria2Status(''); + renderAria2Summary({ + backend: 'aria2', + configured_path: data.aria2c_path, + resolved_path: data.aria2c_path, + available: true, + version: data.version, + running: false + }); await checkAria2Status({ useUnsavedPath: true }); } catch (error) { setAria2Status(error.message || 'aria2 install failed.', 'is-invalid'); + renderAria2Summary({ + backend: 'aria2', + configured_path: aria2cPathInput?.value || '', + resolved_path: '', + available: false, + running: false + }); } finally { if (aria2InstallBtn) aria2InstallBtn.disabled = false; if (aria2CheckBtn) aria2CheckBtn.disabled = false; } }; + const requestAria2Control = async (endpoint, { method = 'POST', body = {} } = {}) => { + const response = await api.fetchApi(endpoint, { + method, + headers: { 'Content-Type': 'application/json' }, + ...(method === 'POST' ? { body: JSON.stringify(body) } : {}) + }); + if (!response.ok) { + let errorMsg = `Server returned ${response.status}: ${response.statusText}`; + try { + const errData = await response.json(); + if (errData?.error) errorMsg = errData.error; + } catch (_) {} + const error = new Error(errorMsg); + error.status = response.status; + throw error; + } + return response.json(); + }; + + const startAria2 = async () => { + if (aria2StopBtn) aria2StopBtn.disabled = true; + setAria2Status(''); + setAria2SummaryValue(aria2DaemonEl, 'Starting...', 'is-pending'); + let refreshed = false; + try { + const result = await requestAria2Control('/model_resolver/aria2/start', { + body: { + aria2c_path: aria2cPathInput?.value || '' + } + }); + if (!result?.success) { + throw new Error(result?.error || 'Could not start aria2 daemon.'); + } + await checkAria2Status({ useUnsavedPath: true }); + refreshed = true; + } catch (error) { + const message = error.message || 'aria2 start failed.'; + await checkAria2Status({ useUnsavedPath: true }); + refreshed = true; + setAria2Status(message, 'is-invalid'); + } finally { + if (!refreshed && aria2StopBtn) aria2StopBtn.disabled = false; + } + }; + + const stopAria2 = async () => { + if (aria2StopBtn) aria2StopBtn.disabled = true; + setAria2Status(''); + setAria2SummaryValue(aria2DaemonEl, 'Stopping...', 'is-pending'); + let refreshed = false; + try { + let result; + try { + result = await requestAria2Control('/model_resolver/aria2/stop'); + } catch (error) { + if (error?.status !== 405) throw error; + result = await requestAria2Control('/model_resolver/aria2/stop', { method: 'GET' }); + } + if (!result?.success) { + throw new Error(result?.error || 'Could not stop aria2 daemon.'); + } + await checkAria2Status({ useUnsavedPath: true }); + refreshed = true; + } catch (error) { + const message = error.message || 'aria2 stop failed.'; + await checkAria2Status({ useUnsavedPath: true }); + refreshed = true; + setAria2Status(message, 'is-invalid'); + } finally { + if (!refreshed && aria2StopBtn) aria2StopBtn.disabled = false; + } + }; + const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => { const value = (input?.value || '').trim(); if (!value) { @@ -1651,6 +1847,26 @@ export const optionsMethods = { }); } + if (aria2StopBtn) { + aria2StopBtn.addEventListener('click', () => { + if (aria2StopBtn.dataset.action === 'start') { + startAria2(); + } else { + stopAria2(); + } + }); + } + + if (aria2cPathInput) { + aria2cPathInput.addEventListener('input', () => { + renderAria2Summary({ + backend: downloadBackendInput?.value || tokens.download_backend, + configured_path: aria2cPathInput.value + }); + setAria2Status(''); + }); + } + if (downloadBackendInput) { downloadBackendInput.addEventListener('change', () => { const backend = this.normalizeDownloadBackend(downloadBackendInput.value); @@ -1774,6 +1990,7 @@ export const optionsMethods = { auto_refresh_comfy_models_after_apply: Boolean(autoRefreshComfyModelsInput?.checked), download_backend: downloadBackend, aria2c_path: aria2cPathInput?.value?.trim() || '', + aria2_auto_stop_daemon: Boolean(aria2AutoStopInput?.checked), download_path_mode: downloadPathMode, download_path_templates: downloadPathTemplates, base_model_path_mappings: baseModelPathMappings, @@ -1801,6 +2018,7 @@ export const optionsMethods = { 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.aria2AutoStopDaemon', newSettings.aria2_auto_stop_daemon ? 'true' : 'false'); 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)); @@ -1885,6 +2103,7 @@ export const optionsMethods = { search_source_enabled, download_backend: this.normalizeDownloadBackend(localStorage.getItem('ModelResolver.downloadBackend') || 'python'), aria2c_path: localStorage.getItem('ModelResolver.aria2cPath') || '', + aria2_auto_stop_daemon: localStorage.getItem('ModelResolver.aria2AutoStopDaemon') !== 'false', download_path_mode: this.getDownloadPathMode(), download_path_templates: this.getDownloadPathTemplates(), base_model_path_mappings: this.getBaseModelPathMappings(), @@ -1944,6 +2163,8 @@ export const optionsMethods = { localStorage.setItem('ModelResolver.downloadBackend', this.normalizeDownloadBackend(data.download_backend)); if (data.aria2c_path !== undefined) localStorage.setItem('ModelResolver.aria2cPath', String(data.aria2c_path || '')); + if (data.aria2_auto_stop_daemon !== undefined) + localStorage.setItem('ModelResolver.aria2AutoStopDaemon', data.aria2_auto_stop_daemon ? 'true' : 'false'); if (data.download_path_mode !== undefined) localStorage.setItem('ModelResolver.downloadPathMode', this.normalizeDownloadPathMode(data.download_path_mode)); if (data.download_path_templates !== undefined)