Add automatic aria2 binary installation support

- New core/aria2_installer.py module downloads the latest aria2c release
  from GitHub for Windows, macOS, and Linux with safe path extraction.
- New POST /model_resolver/aria2/install endpoint triggers installation
  and saves the resolved aria2c_path plus download_backend=aria2.
- Options UI adds an Install aria2 button, hides aria2-specific fields
  when another backend is selected, and updates status on completion.
- Includes .gitignore entry for tools/aria2/ so installed binaries are
  not committed.
This commit is contained in:
Dariusz L
2026-06-29 21:55:27 +02:00
parent 5f1b53e41c
commit 8b15500ce4
5 changed files with 497 additions and 13 deletions
+3
View File
@@ -30,6 +30,9 @@ Thumbs.db
*.cache
scratch/
# Downloaded external tools
tools/aria2/
# User settings – contains API keys and tokens, must NOT be committed
model_resolver_settings.json
tests/
+27
View File
@@ -139,6 +139,7 @@ class ModelResolverExtension:
normalize_download_category,
write_lora_manager_metadata,
)
from .core.aria2_installer import Aria2InstallError, install_aria2_engine
from .core.sources.popular import (
get_popular_model_url,
search_popular_models,
@@ -3539,6 +3540,32 @@ 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/install")
@json_api_endpoint("aria2 install")
async def aria2_install_route(request):
"""Download official aria2c binary and save its path."""
try:
payload = await request.json()
except Exception:
payload = {}
force = False
if isinstance(payload, dict):
force = to_bool(payload.get("force"), False)
try:
install_result = await asyncio.to_thread(install_aria2_engine, force)
except Aria2InstallError as exc:
self.logger.warning(f"Model Resolver aria2 install failed: {exc}")
return web.json_response({"success": False, "error": str(exc)}, status=500)
settings = await asyncio.to_thread(
save_resolver_settings,
{
"aria2c_path": install_result.get("aria2c_path", ""),
"download_backend": "aria2",
},
)
install_result["settings"] = settings
return web.json_response(install_result)
@routes.get("/model_resolver/directories")
@json_api_endpoint("directories")
async def get_directories(request):
+364
View File
@@ -0,0 +1,364 @@
"""Install aria2c from official aria2 GitHub releases."""
from __future__ import annotations
import platform
import shutil
import stat
import tarfile
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Dict, Optional
import requests
from .path_utils import is_path_within
ARIA2_RELEASE_API_URL = "https://api.github.com/repos/aria2/aria2/releases/latest"
REQUEST_HEADERS = {"User-Agent": "ComfyUI-Model-Resolver"}
CUSTOM_NODE_ROOT = Path(__file__).resolve().parents[1]
ARIA2_INSTALL_ROOT = CUSTOM_NODE_ROOT / "tools" / "aria2"
class Aria2InstallError(RuntimeError):
"""Raised when aria2 cannot be installed automatically."""
def _safe_name(value: Any) -> str:
text = str(value or "").strip()
safe = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in text)
return safe.strip("._") or "aria2"
def _machine_bits(machine: str) -> str:
machine = str(machine or "").lower()
if machine in {"amd64", "x86_64", "x64", "arm64", "aarch64"}:
return "64"
if machine in {"x86", "i386", "i686"}:
return "32"
return "64"
def _platform_tokens() -> Dict[str, str]:
system = platform.system().lower()
machine = platform.machine().lower()
if system == "windows":
bits = _machine_bits(machine)
return {
"system": "windows",
"machine": machine,
"label": f"Windows {bits}-bit",
"required": "win",
"arch": f"{bits}bit",
"exe_name": "aria2c.exe",
}
if system == "darwin":
return {
"system": "darwin",
"machine": machine,
"label": "macOS",
"required": "mac",
"arch": "",
"exe_name": "aria2c",
}
if system == "linux":
return {
"system": "linux",
"machine": machine,
"label": f"Linux {machine or 'unknown'}",
"required": "linux",
"arch": "",
"exe_name": "aria2c",
}
raise Aria2InstallError(
f"Automatic aria2 install is not supported on {platform.system() or 'this OS'}."
)
def _asset_name(asset: Dict[str, Any]) -> str:
return str(asset.get("name") or "").strip()
def _asset_url(asset: Dict[str, Any]) -> str:
return str(asset.get("browser_download_url") or "").strip()
def _is_source_archive(name: str) -> bool:
lower = name.lower()
return lower.endswith((".tar.gz", ".tar.bz2", ".tar.xz")) and "build" not in lower
def _score_asset(asset: Dict[str, Any], tokens: Dict[str, str]) -> int:
name = _asset_name(asset)
lower = name.lower()
if not name or not _asset_url(asset):
return -1
if _is_source_archive(name):
return -1
if tokens["system"] != "linux" and "android" in lower:
return -1
score = 0
arch = tokens.get("arch") or ""
if tokens["system"] == "windows":
if "win" not in lower:
return -1
score += 50
if arch and arch in lower:
score += 30
if lower.endswith(".zip"):
score += 10
return score
if tokens["system"] == "darwin":
if not any(token in lower for token in ("mac", "darwin", "osx", "os-x")):
return -1
score += 50
if lower.endswith((".zip", ".tar.gz", ".tar.xz", ".tar.bz2")):
score += 10
return score
if tokens["system"] == "linux":
if "linux" not in lower or "android" in lower:
return -1
score += 50
machine = tokens.get("machine") or ""
if machine and machine in lower:
score += 10
if lower.endswith((".zip", ".tar.gz", ".tar.xz", ".tar.bz2")):
score += 10
return score
return -1
def _select_release_asset(release: Dict[str, Any], tokens: Dict[str, str]) -> Dict[str, Any]:
assets = release.get("assets") or []
if not isinstance(assets, list) or not assets:
raise Aria2InstallError("The latest aria2 release does not expose downloadable assets.")
scored = [
(score, asset)
for asset in assets
for score in [_score_asset(asset, tokens)]
if score >= 0
]
if not scored:
release_name = release.get("tag_name") or release.get("name") or "latest release"
asset_names = ", ".join(_asset_name(asset) for asset in assets if _asset_name(asset))
raise Aria2InstallError(
f"No prebuilt aria2 desktop binary for {tokens['label']} was found in {release_name}. "
"Install aria2 with your system package manager or configure aria2c_path manually. "
f"Available assets: {asset_names or 'none'}"
)
scored.sort(key=lambda item: item[0], reverse=True)
return scored[0][1]
def _fetch_latest_release() -> Dict[str, Any]:
try:
response = requests.get(ARIA2_RELEASE_API_URL, headers=REQUEST_HEADERS, timeout=(10, 30))
response.raise_for_status()
except requests.RequestException as exc:
raise Aria2InstallError(
"Could not contact GitHub to check the latest aria2 release. "
"Check your internet connection, firewall/proxy settings, or configure aria2c_path manually. "
f"Details: {exc}"
) from exc
data = response.json()
if not isinstance(data, dict):
raise Aria2InstallError("GitHub returned an invalid aria2 release response.")
return data
def _assert_within_install_root(path: Path) -> None:
install_root = ARIA2_INSTALL_ROOT.resolve()
target = path.resolve()
if target == install_root:
return
if not is_path_within(str(target), str(install_root)):
raise Aria2InstallError(f"Refusing to write outside aria2 install root: {target}")
def _safe_extract_zip(archive_path: Path, destination: Path) -> None:
destination_abs = destination.resolve()
with zipfile.ZipFile(archive_path) as archive:
for member in archive.infolist():
target = destination_abs / member.filename
target_abs = target.resolve()
if not is_path_within(str(target_abs), str(destination_abs)) and target_abs != destination_abs:
raise Aria2InstallError(f"Unsafe path in aria2 archive: {member.filename}")
archive.extractall(destination_abs)
def _safe_extract_tar(archive_path: Path, destination: Path) -> None:
destination_abs = destination.resolve()
with tarfile.open(archive_path) as archive:
for member in archive.getmembers():
target = destination_abs / member.name
target_abs = target.resolve()
if not is_path_within(str(target_abs), str(destination_abs)) and target_abs != destination_abs:
raise Aria2InstallError(f"Unsafe path in aria2 archive: {member.name}")
archive.extractall(destination_abs)
def _extract_archive(archive_path: Path, destination: Path) -> None:
name = archive_path.name.lower()
if name.endswith(".zip"):
_safe_extract_zip(archive_path, destination)
return
if name.endswith((".tar.gz", ".tgz", ".tar.xz", ".tar.bz2")):
_safe_extract_tar(archive_path, destination)
return
raise Aria2InstallError(f"Unsupported aria2 archive format: {archive_path.name}")
def _find_executable(root: Path, exe_name: str) -> Optional[Path]:
if not root.exists():
return None
expected = exe_name.lower()
for path in root.rglob("*"):
if path.is_file() and path.name.lower() == expected:
return path
return None
def _find_existing_install(exe_name: str) -> Optional[Path]:
candidates = []
expected = exe_name.lower()
if not ARIA2_INSTALL_ROOT.exists():
return None
for path in ARIA2_INSTALL_ROOT.rglob("*"):
if path.is_file() and path.name.lower() == expected:
candidates.append(path)
if not candidates:
return None
candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
return candidates[0]
def _existing_install_metadata(executable: Path) -> Dict[str, str]:
try:
relative = executable.relative_to(ARIA2_INSTALL_ROOT)
parts = relative.parts
except ValueError:
parts = ()
return {
"version": parts[0] if parts else "",
"asset": parts[1] if len(parts) > 1 else "",
"install_dir": str(executable.parent),
}
def _download_file(url: str, destination: Path) -> int:
total = 0
try:
with requests.get(url, headers=REQUEST_HEADERS, stream=True, timeout=(10, 120)) as response:
response.raise_for_status()
with destination.open("wb") as handle:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if not chunk:
continue
handle.write(chunk)
total += len(chunk)
except requests.RequestException as exc:
raise Aria2InstallError(
"Could not download the aria2 release asset from GitHub. "
"Check your internet connection, firewall/proxy settings, or configure aria2c_path manually. "
f"Details: {exc}"
) from exc
return total
def _chmod_executable(path: Path) -> None:
if platform.system().lower() == "windows":
return
try:
mode = path.stat().st_mode
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
except OSError:
pass
def install_aria2_engine(force: bool = False) -> Dict[str, Any]:
"""Download and install aria2c for the current platform."""
tokens = _platform_tokens()
if not force:
existing_executable = _find_existing_install(tokens["exe_name"])
if existing_executable and existing_executable.exists():
_chmod_executable(existing_executable)
existing_metadata = _existing_install_metadata(existing_executable)
return {
"success": True,
"already_installed": True,
"aria2c_path": str(existing_executable),
"version": existing_metadata["version"],
"asset": existing_metadata["asset"],
"platform": tokens["label"],
"install_dir": existing_metadata["install_dir"],
}
release = _fetch_latest_release()
asset = _select_release_asset(release, tokens)
tag_name = str(release.get("tag_name") or "latest").strip() or "latest"
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)
_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:
_chmod_executable(existing_executable)
return {
"success": True,
"already_installed": True,
"aria2c_path": str(existing_executable),
"version": tag_name,
"asset": asset_name,
"platform": tokens["label"],
"install_dir": str(install_dir),
}
ARIA2_INSTALL_ROOT.mkdir(parents=True, exist_ok=True)
install_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="model-resolver-aria2-") as tmp_dir_text:
tmp_dir = Path(tmp_dir_text)
archive_path = tmp_dir / asset_name
downloaded_bytes = _download_file(asset_url, archive_path)
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)
executable = _find_executable(install_dir, tokens["exe_name"])
if not executable:
raise Aria2InstallError(
f"Installed aria2 archive did not contain {tokens['exe_name']}."
)
_chmod_executable(executable)
return {
"success": True,
"already_installed": False,
"aria2c_path": str(executable),
"version": tag_name,
"asset": asset_name,
"platform": tokens["label"],
"install_dir": str(install_dir),
"downloaded_bytes": downloaded_bytes,
"download_url": asset_url,
}
+12
View File
@@ -6842,6 +6842,14 @@ a.mr-image-preview-resource-name:hover {
min-width: 0;
}
.mr-options-wide-row {
grid-template-columns: minmax(150px, 220px) minmax(260px, 1fr);
}
.mr-options-aria2-setting[hidden] {
display: none !important;
}
.mr-options-path-mode-row {
grid-template-columns: minmax(150px, 1fr) minmax(220px, 280px);
}
@@ -6923,6 +6931,10 @@ a.mr-image-preview-resource-name:hover {
grid-template-columns: minmax(120px, 1fr) minmax(220px, 260px);
}
.mr-options-wide-row {
grid-template-columns: minmax(120px, 180px) minmax(220px, 1fr);
}
.mr-options-toggle-row {
gap: 10px;
}
+91 -13
View File
@@ -235,7 +235,7 @@ export const optionsMethods = {
<h5 class="mr-options-subsection-title">Download Engine</h5>
<span class="mr-tooltip-badge" data-tooltip="Choose how model files are downloaded. aria2 requires the external aria2c executable and is intended for large downloads.">?</span>
</div>
<div class="mr-options-number-row">
<div class="mr-options-number-row mr-options-wide-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">Download backend</span>
</div>
@@ -243,22 +243,31 @@ export const optionsMethods = {
${renderDownloadBackendOptions(tokens.download_backend)}
</select>
</div>
<div class="mr-options-number-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">aria2c path <span class="mr-tooltip-badge" data-tooltip="Optional full path to aria2c/aria2c.exe. Leave empty to use aria2c from your system PATH.">?</span></span>
<div class="mr-options-dependent-block mr-options-aria2-setting">
<div class="mr-options-number-row mr-options-wide-row">
<div class="mr-options-number-copy">
<span class="mr-options-label">aria2c path <span class="mr-tooltip-badge" data-tooltip="Optional full path to aria2c/aria2c.exe. Leave empty to use aria2c from your system PATH.">?</span></span>
</div>
<input id="mr-options-aria2c-path" class="mr-options-input" type="text" value="${this.escapeHtml(tokens.aria2c_path || '')}" placeholder="Leave empty to use aria2c from PATH">
</div>
<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 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>
<div class="mr-options-db-actions">
<button id="mr-options-aria2-check" type="button" class="mr-btn mr-btn-secondary">${getSvgIcon('refreshCw')} Check aria2</button>
<a class="mr-options-inline-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener noreferrer">Setup guide</a>
</div>
<div id="mr-options-aria2-status" class="mr-options-db-message">Python downloader is used by default. aria2 requires aria2c installed or a configured executable path.</div>
</div>
</div>
<div class="mr-options-panel">
<div class="mr-options-toggle-list">
${sourceDefaultsRows}
<div class="mr-options-stack">
<div class="mr-options-subsection-head">
<h5 class="mr-options-subsection-title">Search Sources</h5>
<span class="mr-tooltip-badge" data-tooltip="Choose which sources Model Resolver can use when searching for downloadable model matches.">?</span>
</div>
<div class="mr-options-toggle-list">
${sourceDefaultsRows}
</div>
</div>
</div>
</div>
@@ -666,8 +675,10 @@ export const optionsMethods = {
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 aria2InstallBtn = this.contentElement.querySelector('#mr-options-aria2-install');
const aria2CheckBtn = this.contentElement.querySelector('#mr-options-aria2-check');
const aria2StatusEl = this.contentElement.querySelector('#mr-options-aria2-status');
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'));
const templatePresetInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-template-preset'));
@@ -779,6 +790,14 @@ export const optionsMethods = {
if (mode) aria2StatusEl.classList.add(mode);
};
const syncAria2OptionsVisibility = () => {
const backend = this.normalizeDownloadBackend(downloadBackendInput?.value || tokens.download_backend);
const showAria2 = backend === 'aria2';
aria2SettingEls.forEach((element) => {
element.hidden = !showAria2;
});
};
const checkAria2Status = async ({ useUnsavedPath = true } = {}) => {
if (aria2CheckBtn) aria2CheckBtn.disabled = true;
setAria2Status('Checking aria2...', 'is-pending');
@@ -806,6 +825,47 @@ 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');
try {
const data = await this.fetchJson('/model_resolver/aria2/install', {
method: 'POST',
body: JSON.stringify({}),
silent: true
}, 'Install aria2');
if (!data?.success || !data?.aria2c_path) {
throw new Error(data?.error || 'aria2 install did not return an executable path.');
}
if (aria2cPathInput) {
aria2cPathInput.value = data.aria2c_path;
aria2cPathInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (downloadBackendInput) {
downloadBackendInput.value = 'aria2';
downloadBackendInput.dispatchEvent(new Event('change', { bubbles: true }));
}
tokens.aria2c_path = data.aria2c_path;
tokens.download_backend = 'aria2';
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');
await checkAria2Status({ useUnsavedPath: true });
} catch (error) {
setAria2Status(error.message || 'aria2 install failed.', 'is-invalid');
} finally {
if (aria2InstallBtn) aria2InstallBtn.disabled = false;
if (aria2CheckBtn) aria2CheckBtn.disabled = false;
}
};
const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => {
const value = (input?.value || '').trim();
if (!value) {
@@ -1585,7 +1645,25 @@ export const optionsMethods = {
});
}
if (tokens.download_backend === 'aria2') {
if (aria2InstallBtn) {
aria2InstallBtn.addEventListener('click', () => {
installAria2();
});
}
if (downloadBackendInput) {
downloadBackendInput.addEventListener('change', () => {
const backend = this.normalizeDownloadBackend(downloadBackendInput.value);
syncAria2OptionsVisibility();
if (backend === 'aria2') {
checkAria2Status({ useUnsavedPath: true });
}
});
}
syncAria2OptionsVisibility();
if (this.normalizeDownloadBackend(tokens.download_backend) === 'aria2') {
checkAria2Status({ useUnsavedPath: false });
}