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:
+27
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user