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
This commit is contained in:
Dariusz L
2026-06-30 00:03:08 +02:00
parent 8b15500ce4
commit 54f861a02e
6 changed files with 562 additions and 17 deletions
+27
View File
@@ -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):
+102 -7
View File
@@ -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 '<unknown>'} 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),
+172 -1
View File
@@ -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(
+3
View File
@@ -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
)
+28
View File
@@ -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;
+230 -9
View File
@@ -250,12 +250,38 @@ export const optionsMethods = {
</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-summary mr-options-aria2-summary">
<div class="mr-options-db-row">
<span>Backend</span>
<strong id="mr-options-aria2-backend-state">aria2</strong>
</div>
<div class="mr-options-db-row">
<span>Status</span>
<strong id="mr-options-aria2-availability">Not checked</strong>
</div>
<div class="mr-options-db-row">
<span>Daemon</span>
<span class="mr-options-aria2-daemon-cell">
<button id="mr-options-aria2-stop" type="button" class="mr-btn mr-btn-secondary mr-btn-sm" hidden>Stop</button>
<strong id="mr-options-aria2-daemon">Not checked</strong>
</span>
</div>
</div>
<label class="mr-options-toggle-row mr-options-compact-toggle-row">
<div class="mr-options-toggle-copy">
<span class="mr-options-toggle-title">Auto-stop daemon after 5 minutes idle <span class="mr-tooltip-badge" data-tooltip="When enabled, Model Resolver stops only the aria2 daemon it started itself after 5 minutes without active downloads. It will not stop external aria2 processes.">?</span></span>
</div>
<span class="mr-options-toggle-control">
<input id="mr-options-aria2-auto-stop" class="mr-options-switch-input" type="checkbox" ${tokens.aria2_auto_stop_daemon ? 'checked' : ''}>
<span class="mr-options-switch"></span>
</span>
</label>
<div class="mr-options-db-actions">
<button id="mr-options-aria2-install" type="button" class="mr-btn mr-btn-primary">${getSvgIcon('download')} Install aria2</button>
<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/Azornes/Comfyui-Model-Resolver/wiki/Aria2-Download-Backend" target="_blank" rel="noopener noreferrer">Setup guide</a>
</div>
<div id="mr-options-aria2-status" class="mr-options-db-message">aria2 requires aria2c installed or a configured executable path.</div>
<div id="mr-options-aria2-status" class="mr-options-db-message" hidden></div>
</div>
</div>
</div>
@@ -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)