feat: add aria2 download backend with pause/resume support
Integrates aria2c as an alternative download backend alongside the built-in Python downloader. Adds settings for selecting the backend and configuring the aria2c executable path, plus a status check button in the options panel. Backend changes: - core/downloader.py: aria2 daemon lifecycle, JSON-RPC client, pause/resume state machine, and transfer tracking - core/settings.py: download_backend normalization and aria2c_path setting - __init__.py: new API routes for pause, resume, and aria2 status checks Frontend changes: - options_methods.js: backend selector, aria2c path input, check button, and local storage persistence - resolve_download_methods.js: pause/resume handlers, pending status transitions, and progress rendering for aria2 downloads - queue_methods.js: queue panel pause/resume buttons, backend badge, and debounced panel updates - search_panel.js: actionsHtml support for composite action buttons
This commit is contained in:
+40
@@ -131,6 +131,9 @@ class ModelResolverExtension:
|
||||
get_progress,
|
||||
get_all_progress,
|
||||
cancel_download,
|
||||
pause_download,
|
||||
resume_download,
|
||||
get_aria2_status,
|
||||
get_download_directory,
|
||||
get_metadata_sidecar_path,
|
||||
normalize_download_category,
|
||||
@@ -3460,6 +3463,7 @@ class ModelResolverExtension:
|
||||
"category": category,
|
||||
"path": target_path,
|
||||
"directory": target_directory,
|
||||
"download_backend": settings.get("download_backend", "python"),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3492,6 +3496,42 @@ class ModelResolverExtension:
|
||||
cancel_download(download_id)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/model_resolver/pause/{download_id}")
|
||||
@json_api_endpoint("pause", return_success_on_error=True)
|
||||
async def pause_download_route(request):
|
||||
"""Pause an aria2 download."""
|
||||
download_id = request.match_info["download_id"]
|
||||
result = pause_download(download_id)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
@routes.post("/model_resolver/resume/{download_id}")
|
||||
@json_api_endpoint("resume", return_success_on_error=True)
|
||||
async def resume_download_route(request):
|
||||
"""Resume an aria2 download."""
|
||||
download_id = request.match_info["download_id"]
|
||||
result = resume_download(download_id)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
@routes.get("/model_resolver/aria2/status")
|
||||
@json_api_endpoint("aria2 status")
|
||||
async def aria2_status_route(request):
|
||||
"""Report aria2 availability using saved settings."""
|
||||
settings = await asyncio.to_thread(load_resolver_settings)
|
||||
return web.json_response(await asyncio.to_thread(get_aria2_status, settings))
|
||||
|
||||
@routes.post("/model_resolver/aria2/status")
|
||||
@json_api_endpoint("aria2 status POST")
|
||||
async def aria2_status_check_route(request):
|
||||
"""Report aria2 availability using an optional unsaved aria2c path."""
|
||||
payload = await request.json()
|
||||
settings = await asyncio.to_thread(load_resolver_settings)
|
||||
if isinstance(payload, dict) and "aria2c_path" in payload:
|
||||
settings = dict(settings)
|
||||
settings["aria2c_path"] = payload.get("aria2c_path", "")
|
||||
return web.json_response(await asyncio.to_thread(get_aria2_status, settings))
|
||||
|
||||
@routes.get("/model_resolver/directories")
|
||||
@json_api_endpoint("directories")
|
||||
async def get_directories(request):
|
||||
|
||||
+582
-1
@@ -7,6 +7,10 @@ Handles downloading models from various sources with progress tracking.
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
@@ -35,13 +39,21 @@ except ImportError:
|
||||
download_progress: Dict[str, Dict[str, Any]] = {}
|
||||
download_lock = threading.Lock()
|
||||
cancelled_downloads: set = set()
|
||||
aria2_lock = threading.RLock()
|
||||
aria2_process: Optional[subprocess.Popen] = None
|
||||
aria2_rpc_url = ""
|
||||
aria2_rpc_secret = ""
|
||||
aria2_transfers: Dict[str, Dict[str, str]] = {}
|
||||
aria2_action_locks: Dict[str, threading.Lock] = {}
|
||||
aria2_desired_states: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Speed calculation settings
|
||||
SPEED_HISTORY_SIZE = 5 # Number of samples for smoothing
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB chunks for faster downloads
|
||||
CLI_LOG_INTERVAL = 5 # Log progress to CLI every N seconds
|
||||
ARIA2_RPC_TIMEOUT = (2, 5) # local JSON-RPC should respond quickly
|
||||
|
||||
from .settings import CATEGORY_MAP, normalize_download_category
|
||||
from .settings import CATEGORY_MAP, load_settings, normalize_download_backend, normalize_download_category
|
||||
|
||||
SENSITIVE_METADATA_KEYS = {
|
||||
"authorization",
|
||||
@@ -664,6 +676,429 @@ def generate_download_id() -> str:
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
class Aria2Error(RuntimeError):
|
||||
"""Raised when the aria2 backend cannot start or process a request."""
|
||||
|
||||
|
||||
def _download_backend_from_settings(settings: Optional[Dict[str, Any]] = None) -> str:
|
||||
active_settings = settings if isinstance(settings, dict) else load_settings()
|
||||
return normalize_download_backend(active_settings.get("download_backend"))
|
||||
|
||||
|
||||
def _try_certifi_ca_path() -> str:
|
||||
try:
|
||||
import certifi # type: ignore
|
||||
|
||||
path = certifi.where()
|
||||
return path if path and os.path.isfile(path) else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_aria2c_executable(settings: Optional[Dict[str, Any]] = None) -> str:
|
||||
active_settings = settings if isinstance(settings, dict) else load_settings()
|
||||
configured = str(active_settings.get("aria2c_path") or "").strip()
|
||||
candidate = os.path.expandvars(os.path.expanduser(configured or "aria2c"))
|
||||
|
||||
resolved = shutil.which(candidate)
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
if configured and os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
raise Aria2Error(
|
||||
"aria2c executable was not found. Install aria2 or configure aria2c_path."
|
||||
)
|
||||
|
||||
|
||||
def get_aria2_status(settings: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
active_settings = settings if isinstance(settings, dict) else load_settings()
|
||||
configured_path = str(active_settings.get("aria2c_path") or "").strip()
|
||||
try:
|
||||
resolved_path = _resolve_aria2c_executable(active_settings)
|
||||
available = True
|
||||
error = ""
|
||||
except Exception as exc:
|
||||
resolved_path = ""
|
||||
available = False
|
||||
error = str(exc)
|
||||
|
||||
with aria2_lock:
|
||||
running = aria2_process is not None and aria2_process.poll() is None
|
||||
|
||||
return {
|
||||
"backend": _download_backend_from_settings(active_settings),
|
||||
"configured_path": configured_path,
|
||||
"resolved_path": resolved_path,
|
||||
"available": available,
|
||||
"running": running,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(1)
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _aria2_rpc(method: str, params: Optional[List[Any]] = None) -> Any:
|
||||
if not aria2_rpc_url:
|
||||
raise Aria2Error("aria2 RPC endpoint is not initialized")
|
||||
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": secrets.token_hex(8),
|
||||
"method": method,
|
||||
"params": [f"token:{aria2_rpc_secret}", *(params or [])],
|
||||
}
|
||||
response = requests.post(aria2_rpc_url, json=payload, timeout=ARIA2_RPC_TIMEOUT)
|
||||
text = response.text
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise Aria2Error(
|
||||
f"aria2 RPC returned non-JSON response ({response.status_code}): {text[:300]}"
|
||||
) from exc
|
||||
|
||||
if "error" in body:
|
||||
error = body.get("error") or {}
|
||||
message = error.get("message") if isinstance(error, dict) else str(error)
|
||||
raise Aria2Error(message or f"aria2 RPC {method} failed")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Aria2Error(
|
||||
f"aria2 RPC {method} returned HTTP {response.status_code}: {text[:300]}"
|
||||
)
|
||||
|
||||
return body.get("result")
|
||||
|
||||
|
||||
def _aria2_ping() -> bool:
|
||||
try:
|
||||
result = _aria2_rpc("aria2.getVersion", [])
|
||||
return isinstance(result, dict)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_aria2_daemon(settings: Optional[Dict[str, Any]] = None) -> None:
|
||||
global aria2_process, aria2_rpc_url, aria2_rpc_secret
|
||||
|
||||
active_settings = settings if isinstance(settings, dict) else load_settings()
|
||||
with aria2_lock:
|
||||
if aria2_process is not None and aria2_process.poll() is None and _aria2_ping():
|
||||
return
|
||||
|
||||
if aria2_process is not None and aria2_process.poll() is None:
|
||||
try:
|
||||
aria2_process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
aria2_process = None
|
||||
|
||||
executable = _resolve_aria2c_executable(active_settings)
|
||||
port = _find_free_port()
|
||||
aria2_rpc_secret = secrets.token_hex(16)
|
||||
aria2_rpc_url = f"http://127.0.0.1:{port}/jsonrpc"
|
||||
|
||||
command = [
|
||||
executable,
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-all=false",
|
||||
f"--rpc-listen-port={port}",
|
||||
f"--rpc-secret={aria2_rpc_secret}",
|
||||
"--check-certificate=true",
|
||||
"--allow-overwrite=true",
|
||||
"--auto-file-renaming=false",
|
||||
"--file-allocation=none",
|
||||
"--max-concurrent-downloads=5",
|
||||
"--continue=true",
|
||||
"--daemon=false",
|
||||
"--quiet=true",
|
||||
f"--stop-with-process={os.getpid()}",
|
||||
]
|
||||
ca_cert = _try_certifi_ca_path()
|
||||
if ca_cert:
|
||||
command.insert(5, f"--ca-certificate={ca_cert}")
|
||||
|
||||
creationflags = 0
|
||||
if os.name == "nt":
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
log_info(f"Starting aria2 RPC daemon from {executable}")
|
||||
aria2_process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
last_error = ""
|
||||
while time.time() - start_time < 10:
|
||||
if aria2_process.poll() is not None:
|
||||
stderr = ""
|
||||
try:
|
||||
stderr = (aria2_process.stderr.read() if aria2_process.stderr else b"").decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except Exception:
|
||||
stderr = ""
|
||||
raise Aria2Error(
|
||||
f"aria2 RPC process exited early with code {aria2_process.returncode}: {stderr.strip()}"
|
||||
)
|
||||
try:
|
||||
if _aria2_ping():
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
time.sleep(0.2)
|
||||
|
||||
raise Aria2Error(
|
||||
f"Timed out waiting for aria2 RPC to become ready{': ' + last_error if last_error else ''}"
|
||||
)
|
||||
|
||||
|
||||
def _aria2_tell_status(gid: str) -> Dict[str, Any]:
|
||||
keys = [
|
||||
"gid",
|
||||
"status",
|
||||
"totalLength",
|
||||
"completedLength",
|
||||
"downloadSpeed",
|
||||
"errorMessage",
|
||||
"files",
|
||||
]
|
||||
result = _aria2_rpc("aria2.tellStatus", [gid, keys])
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def _parse_aria2_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_aria2_completed_path(status: Dict[str, Any], default_path: str) -> str:
|
||||
files = status.get("files")
|
||||
if isinstance(files, list) and files:
|
||||
first = files[0]
|
||||
if isinstance(first, dict):
|
||||
candidate = first.get("path")
|
||||
if isinstance(candidate, str) and candidate:
|
||||
return candidate
|
||||
return default_path
|
||||
|
||||
|
||||
def _delete_partial_download_files(dest_path: str) -> None:
|
||||
for path in (dest_path, f"{dest_path}.aria2"):
|
||||
try:
|
||||
if path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
except Exception as exc:
|
||||
log_warn(f"Could not delete incomplete download file {path}: {exc}")
|
||||
|
||||
|
||||
def download_file_with_aria2(
|
||||
url: str,
|
||||
dest_path: str,
|
||||
download_id: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
category: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Download a file with an aria2c JSON-RPC process."""
|
||||
settings = load_settings()
|
||||
result = {
|
||||
"success": False,
|
||||
"download_id": download_id,
|
||||
"path": dest_path,
|
||||
"error": None,
|
||||
"size": 0,
|
||||
}
|
||||
start_time = time.time()
|
||||
filename = os.path.basename(dest_path)
|
||||
|
||||
with download_lock:
|
||||
download_progress[download_id] = {
|
||||
"status": "starting",
|
||||
"progress": 0,
|
||||
"total_size": 0,
|
||||
"downloaded": 0,
|
||||
"filename": filename,
|
||||
"path": dest_path,
|
||||
"directory": os.path.dirname(dest_path),
|
||||
"url": url,
|
||||
"error": None,
|
||||
"speed": 0,
|
||||
"start_time": start_time,
|
||||
"download_backend": "aria2",
|
||||
}
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
_ensure_aria2_daemon(settings)
|
||||
|
||||
options: Dict[str, Any] = {
|
||||
"dir": os.path.dirname(dest_path),
|
||||
"out": filename,
|
||||
"continue": "true",
|
||||
"max-connection-per-server": "4",
|
||||
"split": "4",
|
||||
"min-split-size": "1M",
|
||||
"allow-overwrite": "true",
|
||||
"auto-file-renaming": "false",
|
||||
"file-allocation": "none",
|
||||
}
|
||||
if headers:
|
||||
options["header"] = [f"{key}: {value}" for key, value in headers.items()]
|
||||
|
||||
gid = _aria2_rpc("aria2.addUri", [[url], options])
|
||||
if not isinstance(gid, str) or not gid:
|
||||
raise Aria2Error("aria2 did not return a download gid")
|
||||
|
||||
with aria2_lock:
|
||||
aria2_transfers[download_id] = {"gid": gid, "path": dest_path}
|
||||
with download_lock:
|
||||
download_progress[download_id]["aria2_gid"] = gid
|
||||
download_progress[download_id]["status"] = "downloading"
|
||||
|
||||
log_info(f"Starting aria2 download: {filename}")
|
||||
last_cli_log = start_time
|
||||
|
||||
while True:
|
||||
if download_id in cancelled_downloads:
|
||||
try:
|
||||
_aria2_rpc("aria2.forceRemove", [gid])
|
||||
except Exception:
|
||||
pass
|
||||
with download_lock:
|
||||
if download_id in download_progress:
|
||||
download_progress[download_id]["status"] = "cancelled"
|
||||
download_progress[download_id]["speed"] = 0
|
||||
_delete_partial_download_files(dest_path)
|
||||
cancelled_downloads.discard(download_id)
|
||||
result["error"] = "Download cancelled"
|
||||
return result
|
||||
|
||||
status = _aria2_tell_status(gid)
|
||||
state = str(status.get("status") or "")
|
||||
total_size = _parse_aria2_int(status.get("totalLength"))
|
||||
downloaded = _parse_aria2_int(status.get("completedLength"))
|
||||
speed = _parse_aria2_int(status.get("downloadSpeed"))
|
||||
progress = int((downloaded / total_size) * 100) if total_size > 0 else 0
|
||||
mapped_status = {
|
||||
"active": "downloading",
|
||||
"waiting": "downloading",
|
||||
"paused": "paused",
|
||||
"complete": "completed",
|
||||
"error": "error",
|
||||
"removed": "cancelled",
|
||||
}.get(state, state or "downloading")
|
||||
|
||||
with download_lock:
|
||||
if download_id in download_progress:
|
||||
download_progress[download_id].update(
|
||||
{
|
||||
"status": mapped_status,
|
||||
"progress": max(0, min(progress, 100)),
|
||||
"total_size": total_size,
|
||||
"downloaded": downloaded,
|
||||
"speed": 0 if mapped_status in {"paused", "completed"} else speed,
|
||||
"download_backend": "aria2",
|
||||
"aria2_gid": gid,
|
||||
}
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
if now - last_cli_log >= CLI_LOG_INTERVAL and mapped_status == "downloading":
|
||||
last_cli_log = now
|
||||
total_str = format_bytes(total_size) if total_size else "?"
|
||||
log_info(
|
||||
f"aria2 progress: {format_bytes(downloaded)} / {total_str} ({progress}%) - {format_bytes(speed)}/s"
|
||||
)
|
||||
|
||||
if state == "complete":
|
||||
completed_path = _resolve_aria2_completed_path(status, dest_path)
|
||||
size = os.path.getsize(completed_path) if os.path.exists(completed_path) else downloaded
|
||||
metadata_path = write_lora_manager_metadata(
|
||||
completed_path,
|
||||
metadata or {},
|
||||
category,
|
||||
url,
|
||||
)
|
||||
with download_lock:
|
||||
download_progress[download_id].update(
|
||||
{
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"downloaded": size,
|
||||
"total_size": total_size or size,
|
||||
"speed": 0,
|
||||
"path": completed_path,
|
||||
"directory": os.path.dirname(completed_path),
|
||||
}
|
||||
)
|
||||
if metadata_path:
|
||||
download_progress[download_id]["metadata_path"] = metadata_path
|
||||
result.update(
|
||||
{
|
||||
"success": True,
|
||||
"path": completed_path,
|
||||
"size": size,
|
||||
"metadata_path": metadata_path,
|
||||
}
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
avg_speed = size / elapsed if elapsed > 0 else 0
|
||||
log_info(f"✓ aria2 download complete: {filename}")
|
||||
log_info(
|
||||
f"Size: {format_bytes(size)}, Time: {elapsed:.1f}s, Avg speed: {format_bytes(int(avg_speed))}/s"
|
||||
)
|
||||
return result
|
||||
|
||||
if state == "error":
|
||||
error_msg = status.get("errorMessage") or "aria2 download failed"
|
||||
with download_lock:
|
||||
download_progress[download_id]["status"] = "error"
|
||||
download_progress[download_id]["error"] = error_msg
|
||||
result["error"] = error_msg
|
||||
return result
|
||||
|
||||
if state == "removed":
|
||||
with download_lock:
|
||||
download_progress[download_id]["status"] = "cancelled"
|
||||
download_progress[download_id]["speed"] = 0
|
||||
_delete_partial_download_files(dest_path)
|
||||
cancelled_downloads.discard(download_id)
|
||||
result["error"] = "Download cancelled"
|
||||
return result
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
with download_lock:
|
||||
if download_id in download_progress:
|
||||
download_progress[download_id]["status"] = "error"
|
||||
download_progress[download_id]["error"] = error_msg
|
||||
download_progress[download_id]["speed"] = 0
|
||||
result["error"] = error_msg
|
||||
log_error(f"✗ aria2 download failed: {filename}")
|
||||
log_error(f"Error: {error_msg}")
|
||||
return result
|
||||
finally:
|
||||
with aria2_lock:
|
||||
aria2_transfers.pop(download_id, None)
|
||||
aria2_action_locks.pop(download_id, None)
|
||||
aria2_desired_states.pop(download_id, None)
|
||||
|
||||
|
||||
def download_file(
|
||||
url: str,
|
||||
dest_path: str,
|
||||
@@ -692,6 +1127,16 @@ def download_file(
|
||||
"""
|
||||
global download_progress, cancelled_downloads
|
||||
|
||||
if _download_backend_from_settings() == "aria2":
|
||||
return download_file_with_aria2(
|
||||
url,
|
||||
dest_path,
|
||||
download_id,
|
||||
headers=headers,
|
||||
metadata=metadata,
|
||||
category=category,
|
||||
)
|
||||
|
||||
# Use default 1MB chunk size if not specified
|
||||
if chunk_size is None:
|
||||
chunk_size = CHUNK_SIZE
|
||||
@@ -724,6 +1169,7 @@ def download_file(
|
||||
"error": None,
|
||||
"speed": 0, # bytes per second
|
||||
"start_time": start_time,
|
||||
"download_backend": "python",
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1103,12 +1549,145 @@ def get_all_progress() -> Dict[str, Dict[str, Any]]:
|
||||
return {k: v.copy() for k, v in download_progress.items()}
|
||||
|
||||
|
||||
def _force_remove_aria2_transfer(download_id: str, gid: str) -> None:
|
||||
try:
|
||||
_aria2_rpc("aria2.forceRemove", [gid])
|
||||
except Exception as exc:
|
||||
log_warn(f"Could not cancel aria2 download {download_id}: {exc}")
|
||||
|
||||
|
||||
def _get_aria2_action_lock(download_id: str) -> threading.Lock:
|
||||
with aria2_lock:
|
||||
lock = aria2_action_locks.get(download_id)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
aria2_action_locks[download_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def _set_download_progress_status(download_id: str, status: str, **updates: Any) -> None:
|
||||
with download_lock:
|
||||
if download_id in download_progress:
|
||||
download_progress[download_id]["status"] = status
|
||||
download_progress[download_id].update(updates)
|
||||
|
||||
|
||||
def _aria2_action_error_is_ok(status: str, message: str) -> bool:
|
||||
lowered = str(message or "").lower()
|
||||
if status == "paused":
|
||||
return "already paused" in lowered or "is paused" in lowered
|
||||
if status == "downloading":
|
||||
return "not paused" in lowered or "has not been paused" in lowered
|
||||
return False
|
||||
|
||||
|
||||
def _run_aria2_desired_state_worker(download_id: str) -> None:
|
||||
while True:
|
||||
with aria2_lock:
|
||||
desired = dict(aria2_desired_states.get(download_id) or {})
|
||||
if not desired or download_id in cancelled_downloads:
|
||||
with aria2_lock:
|
||||
state = aria2_desired_states.get(download_id)
|
||||
if state:
|
||||
state["running"] = False
|
||||
return
|
||||
|
||||
desired_status = str(desired.get("status") or "")
|
||||
desired_seq = int(desired.get("seq") or 0)
|
||||
transfer = aria2_transfers.get(download_id)
|
||||
gid = transfer.get("gid") if isinstance(transfer, dict) else ""
|
||||
if not gid:
|
||||
with aria2_lock:
|
||||
aria2_desired_states.pop(download_id, None)
|
||||
return
|
||||
|
||||
method = "aria2.forcePause" if desired_status == "paused" else "aria2.unpause"
|
||||
try:
|
||||
with _get_aria2_action_lock(download_id):
|
||||
_aria2_rpc(method, [gid])
|
||||
_set_download_progress_status(
|
||||
download_id,
|
||||
desired_status,
|
||||
speed=0 if desired_status == "paused" else download_progress.get(download_id, {}).get("speed", 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
if _aria2_action_error_is_ok(desired_status, str(exc)):
|
||||
_set_download_progress_status(download_id, desired_status, speed=0 if desired_status == "paused" else download_progress.get(download_id, {}).get("speed", 0))
|
||||
else:
|
||||
log_warn(f"aria2 {desired_status} action failed for {download_id}: {exc}")
|
||||
|
||||
with aria2_lock:
|
||||
latest = aria2_desired_states.get(download_id)
|
||||
if not latest:
|
||||
return
|
||||
if int(latest.get("seq") or 0) == desired_seq:
|
||||
aria2_desired_states.pop(download_id, None)
|
||||
return
|
||||
|
||||
|
||||
def _queue_aria2_desired_state(download_id: str, status: str) -> Dict[str, Any]:
|
||||
transfer = aria2_transfers.get(download_id)
|
||||
if not transfer or not transfer.get("gid"):
|
||||
return {"success": False, "error": "Download action is not available yet"}
|
||||
|
||||
start_worker = False
|
||||
with aria2_lock:
|
||||
previous = aria2_desired_states.get(download_id) or {}
|
||||
seq = int(previous.get("seq") or 0) + 1
|
||||
running = bool(previous.get("running"))
|
||||
aria2_desired_states[download_id] = {
|
||||
"status": status,
|
||||
"seq": seq,
|
||||
"running": True,
|
||||
}
|
||||
start_worker = not running
|
||||
|
||||
_set_download_progress_status(
|
||||
download_id,
|
||||
status,
|
||||
speed=0 if status == "paused" else download_progress.get(download_id, {}).get("speed", 0),
|
||||
)
|
||||
|
||||
if start_worker:
|
||||
threading.Thread(
|
||||
target=_run_aria2_desired_state_worker,
|
||||
args=(download_id,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
return {"success": True, "message": "Download paused" if status == "paused" else "Download resumed"}
|
||||
|
||||
|
||||
def cancel_download(download_id: str) -> bool:
|
||||
"""Cancel a download in progress."""
|
||||
cancelled_downloads.add(download_id)
|
||||
with aria2_lock:
|
||||
aria2_desired_states.pop(download_id, None)
|
||||
_set_download_progress_status(download_id, "cancelling", speed=0)
|
||||
transfer = aria2_transfers.get(download_id)
|
||||
if transfer and transfer.get("gid"):
|
||||
threading.Thread(
|
||||
target=_force_remove_aria2_transfer,
|
||||
args=(download_id, transfer["gid"]),
|
||||
daemon=True,
|
||||
).start()
|
||||
return True
|
||||
|
||||
|
||||
def pause_download(download_id: str) -> Dict[str, Any]:
|
||||
"""Pause an aria2 download. Built-in Python downloads cannot be paused."""
|
||||
if download_id in cancelled_downloads:
|
||||
return {"success": False, "error": "Download is being cancelled"}
|
||||
return _queue_aria2_desired_state(download_id, "paused")
|
||||
|
||||
|
||||
def resume_download(download_id: str) -> Dict[str, Any]:
|
||||
"""Resume a paused aria2 download."""
|
||||
if download_id in cancelled_downloads:
|
||||
return {"success": False, "error": "Download is being cancelled"}
|
||||
return _queue_aria2_desired_state(download_id, "downloading")
|
||||
|
||||
|
||||
def clear_completed_downloads():
|
||||
"""Clear completed/failed downloads from progress tracking."""
|
||||
with download_lock:
|
||||
@@ -1158,6 +1737,7 @@ def start_background_download(
|
||||
"error": None,
|
||||
"speed": 0,
|
||||
"start_time": time.time(),
|
||||
"download_backend": _download_backend_from_settings(),
|
||||
}
|
||||
|
||||
def run_download():
|
||||
@@ -1200,6 +1780,7 @@ def start_background_download(
|
||||
"error": str(e),
|
||||
"speed": 0,
|
||||
"start_time": time.time(),
|
||||
"download_backend": _download_backend_from_settings(),
|
||||
}
|
||||
|
||||
thread = threading.Thread(target=run_download, daemon=True)
|
||||
|
||||
@@ -14,6 +14,7 @@ SETTINGS_FILE = Path(__file__).resolve().parents[1] / "model_resolver_settings.j
|
||||
from .type_utils import to_bool, CATEGORY_MAP, normalize_download_category
|
||||
|
||||
DOWNLOAD_PATH_MODES = {"suggested", "template", "manual"}
|
||||
DOWNLOAD_BACKENDS = {"python", "aria2"}
|
||||
|
||||
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
|
||||
"loras": "{base_model}/{first_tag}",
|
||||
@@ -155,8 +156,15 @@ def normalize_download_path_mode(value: Any, auto_fill_subfolder: Any = None) ->
|
||||
return "suggested"
|
||||
|
||||
|
||||
def normalize_download_backend(value: Any) -> str:
|
||||
backend = str(value or "").strip().lower()
|
||||
return backend if backend in DOWNLOAD_BACKENDS else "python"
|
||||
|
||||
|
||||
def normalize_settings(settings: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
data = dict(settings or {})
|
||||
data["download_backend"] = normalize_download_backend(data.get("download_backend"))
|
||||
data["aria2c_path"] = str(data.get("aria2c_path") or "").strip()
|
||||
data["auto_refresh_comfy_models_after_apply"] = bool_setting(
|
||||
data.get("auto_refresh_comfy_models_after_apply"), True
|
||||
)
|
||||
|
||||
@@ -128,6 +128,10 @@ export const queueMethods = {
|
||||
|
||||
updateQueuePanel() {
|
||||
if (!this.queueList || !this.queueHeader) return;
|
||||
if (this._queuePanelUpdateTimer) {
|
||||
clearTimeout(this._queuePanelUpdateTimer);
|
||||
this._queuePanelUpdateTimer = null;
|
||||
}
|
||||
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
|
||||
const downloads = this.getActiveQueuePanelDownloads();
|
||||
const history = this.getDownloadHistory();
|
||||
@@ -146,6 +150,15 @@ export const queueMethods = {
|
||||
this.renderQueuedSelections(list);
|
||||
},
|
||||
|
||||
requestQueuePanelUpdate(delayMs = 180) {
|
||||
if (!this.queueList || !this.queueHeader) return;
|
||||
if (this._queuePanelUpdateTimer) return;
|
||||
this._queuePanelUpdateTimer = setTimeout(() => {
|
||||
this._queuePanelUpdateTimer = null;
|
||||
this.updateQueuePanel();
|
||||
}, delayMs);
|
||||
},
|
||||
|
||||
updateQueuePanelTabs(queueCount, downloadCount, activeTab) {
|
||||
const queuedTab = this.queueQueuedTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="queued"]');
|
||||
const downloadsTab = this.queueDownloadsTabButton || this.queueTabs?.querySelector?.('.mr-queue-tab[data-tab="downloads"]');
|
||||
@@ -1089,6 +1102,26 @@ export const queueMethods = {
|
||||
},
|
||||
|
||||
wireDownloadsPanelControls() {
|
||||
const bindInstantAction = (button, handler) => {
|
||||
if (!button || button._hasListener) return;
|
||||
button._hasListener = true;
|
||||
const run = (event) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (button.disabled) return;
|
||||
if (event?.type === 'click' && button._handledPointerAction) {
|
||||
button._handledPointerAction = false;
|
||||
return;
|
||||
}
|
||||
if (event?.type === 'pointerdown') {
|
||||
button._handledPointerAction = true;
|
||||
}
|
||||
handler();
|
||||
};
|
||||
button.addEventListener('pointerdown', run);
|
||||
button.addEventListener('click', run);
|
||||
};
|
||||
|
||||
this.queueList.querySelectorAll('.mr-downloads-subtab').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
this.setQueueDownloadsTab(button.dataset.downloadsTab || 'active');
|
||||
@@ -1096,12 +1129,26 @@ export const queueMethods = {
|
||||
});
|
||||
|
||||
this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
bindInstantAction(button, () => {
|
||||
const downloadId = button.dataset.downloadId;
|
||||
if (downloadId) this.cancelDownload(downloadId);
|
||||
});
|
||||
});
|
||||
|
||||
this.queueList.querySelectorAll('.mr-download-queue-pause').forEach(button => {
|
||||
bindInstantAction(button, () => {
|
||||
const downloadId = button.dataset.downloadId;
|
||||
if (downloadId) this.pauseDownload(downloadId);
|
||||
});
|
||||
});
|
||||
|
||||
this.queueList.querySelectorAll('.mr-download-queue-resume').forEach(button => {
|
||||
bindInstantAction(button, () => {
|
||||
const downloadId = button.dataset.downloadId;
|
||||
if (downloadId) this.resumeDownload(downloadId);
|
||||
});
|
||||
});
|
||||
|
||||
const clearHistoryButton = this.queueList.querySelector('.mr-download-history-clear');
|
||||
if (clearHistoryButton) {
|
||||
clearHistoryButton.addEventListener('click', () => this.clearDownloadHistory());
|
||||
@@ -1119,13 +1166,7 @@ export const queueMethods = {
|
||||
|
||||
renderQueueDownloads(downloads) {
|
||||
this.queueList.innerHTML = this.renderQueueDownloadsHtml(downloads);
|
||||
|
||||
this.queueList.querySelectorAll('.mr-download-queue-cancel').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const downloadId = button.dataset.downloadId;
|
||||
if (downloadId) this.cancelDownload(downloadId);
|
||||
});
|
||||
});
|
||||
this.wireDownloadsPanelControls();
|
||||
},
|
||||
|
||||
renderQueueDownloadsHtml(downloads) {
|
||||
@@ -1135,7 +1176,9 @@ export const queueMethods = {
|
||||
|
||||
let html = '<div class="mr-queue-items mr-download-queue-items">';
|
||||
for (const { downloadId, info } of downloads) {
|
||||
const progress = info.lastProgress || {};
|
||||
const progress = typeof this.applyPendingDownloadStatus === 'function'
|
||||
? this.applyPendingDownloadStatus(info, info.lastProgress || {})
|
||||
: (info.lastProgress || {});
|
||||
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
|
||||
const filename = progress.filename
|
||||
|| info.filename
|
||||
@@ -1148,9 +1191,12 @@ export const queueMethods = {
|
||||
const total = progress.total_size ? this.formatBytes(progress.total_size) : '';
|
||||
const progressMeta = this.formatDownloadProgressMeta?.(progress) || '';
|
||||
const status = progress.status || info.lastStatus || 'starting';
|
||||
const backend = String(progress.download_backend || info.downloadBackend || '').toLowerCase();
|
||||
const isAria2 = backend === 'aria2';
|
||||
const isPaused = status === 'paused';
|
||||
const statusLabel = status === 'downloading'
|
||||
? `${Math.round(percent)}%`
|
||||
: (status === 'starting' ? 'Starting' : status.replace(/_/g, ' '));
|
||||
: (status === 'starting' ? 'Starting' : (isPaused ? 'Paused' : status.replace(/_/g, ' ')));
|
||||
const sizeText = total ? `${downloaded} / ${total}` : downloaded;
|
||||
const targetPath = progress.directory || info.downloadDirectory || info.downloadPath || '';
|
||||
const targetLabel = targetPath ? targetPath.split(/[\/\\]/).filter(Boolean).pop() || targetPath : '';
|
||||
@@ -1174,6 +1220,9 @@ export const queueMethods = {
|
||||
if (category) {
|
||||
html += `<div class="mr-queue-item-meta"><span>Type</span><code>${this.escapeHtml(category)}</code></div>`;
|
||||
}
|
||||
if (backend) {
|
||||
html += `<div class="mr-queue-item-meta"><span>Backend</span><code>${this.escapeHtml(backend)}</code></div>`;
|
||||
}
|
||||
if (targetLabel) {
|
||||
html += `<div class="mr-queue-item-meta"><span>Folder</span><code data-tooltip="${this.escapeHtml(targetPath)}">${this.escapeHtml(targetLabel)}</code></div>`;
|
||||
}
|
||||
@@ -1181,7 +1230,14 @@ export const queueMethods = {
|
||||
html += `<div class="mr-progress-bar"><div class="mr-progress-fill" style="width: ${percent}%;"></div></div>`;
|
||||
html += `<div class="mr-progress-text"><span>${this.escapeHtml(sizeText)}</span><span>${this.escapeHtml(progressMeta)}</span></div>`;
|
||||
html += `</div>`;
|
||||
html += `<div class="mr-queue-item-actions"><button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-download-queue-cancel" data-download-id="${this.escapeHtml(downloadId)}">Cancel</button></div>`;
|
||||
html += `<div class="mr-queue-item-actions">`;
|
||||
if (isAria2 && isPaused) {
|
||||
html += `<button type="button" class="mr-btn mr-btn-primary mr-btn-sm mr-download-queue-resume" data-download-id="${this.escapeHtml(downloadId)}">Resume</button>`;
|
||||
} else if (isAria2 && status === 'downloading') {
|
||||
html += `<button type="button" class="mr-btn mr-btn-secondary mr-btn-sm mr-download-queue-pause" data-download-id="${this.escapeHtml(downloadId)}">Pause</button>`;
|
||||
}
|
||||
html += `<button type="button" class="mr-btn mr-btn-danger mr-btn-sm mr-download-queue-cancel" data-download-id="${this.escapeHtml(downloadId)}">Cancel</button>`;
|
||||
html += `</div>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
@@ -749,7 +749,8 @@ export const resolveDownloadMethods = {
|
||||
status,
|
||||
filename: progress.filename || info.filename || '',
|
||||
path: progress.path || info.downloadPath || '',
|
||||
directory: progress.directory || info.downloadDirectory || ''
|
||||
directory: progress.directory || info.downloadDirectory || '',
|
||||
download_backend: progress.download_backend || info.downloadBackend || ''
|
||||
},
|
||||
status,
|
||||
message: options.message || '',
|
||||
@@ -811,12 +812,35 @@ export const resolveDownloadMethods = {
|
||||
return { progressDiv, downloadBtn };
|
||||
},
|
||||
|
||||
attachDownloadCancelHandler(progressDiv, downloadId) {
|
||||
attachDownloadActionHandlers(progressDiv, downloadId) {
|
||||
if (!progressDiv || !downloadId) return;
|
||||
const bindInstantAction = (button, handler) => {
|
||||
if (!button || button._hasListener) return;
|
||||
button._hasListener = true;
|
||||
const run = (event) => {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
if (button.disabled) return;
|
||||
if (event?.type === 'click' && button._handledPointerAction) {
|
||||
button._handledPointerAction = false;
|
||||
return;
|
||||
}
|
||||
if (event?.type === 'pointerdown') {
|
||||
button._handledPointerAction = true;
|
||||
}
|
||||
handler();
|
||||
};
|
||||
button.addEventListener('pointerdown', run);
|
||||
button.addEventListener('click', run);
|
||||
};
|
||||
progressDiv.querySelectorAll('.cancel-download-btn, .cancel-download-btn-pending').forEach(cancelBtn => {
|
||||
if (cancelBtn._hasListener) return;
|
||||
cancelBtn._hasListener = true;
|
||||
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
|
||||
bindInstantAction(cancelBtn, () => this.cancelDownload(downloadId));
|
||||
});
|
||||
progressDiv.querySelectorAll('.pause-download-btn').forEach(pauseBtn => {
|
||||
bindInstantAction(pauseBtn, () => this.pauseDownload(downloadId));
|
||||
});
|
||||
progressDiv.querySelectorAll('.resume-download-btn').forEach(resumeBtn => {
|
||||
bindInstantAction(resumeBtn, () => this.resumeDownload(downloadId));
|
||||
});
|
||||
},
|
||||
|
||||
@@ -832,6 +856,7 @@ export const resolveDownloadMethods = {
|
||||
const terminalStatuses = new Set(['cancelling', 'cancelled', 'error', 'refresh_error', 'completed_checking', 'completed']);
|
||||
const shouldRenderProgress = status === 'starting'
|
||||
|| status === 'downloading'
|
||||
|| status === 'paused'
|
||||
|| (isActive && !terminalStatuses.has(status));
|
||||
|
||||
if (progressDiv) {
|
||||
@@ -840,23 +865,44 @@ export const resolveDownloadMethods = {
|
||||
|
||||
if (shouldRenderProgress) {
|
||||
const canCancel = Boolean(downloadId);
|
||||
const backend = String(progress.download_backend || snapshot.downloadBackend || snapshot.download_backend || '').toLowerCase();
|
||||
const isAria2 = backend === 'aria2';
|
||||
const isPaused = status === 'paused';
|
||||
const percent = Math.max(0, Math.min(100, Number(progress.progress) || 0));
|
||||
const downloaded = this.formatBytes(progress.downloaded || 0);
|
||||
const total = this.formatBytes(progress.total_size || 0);
|
||||
const progressMeta = this.formatDownloadProgressMeta(progress);
|
||||
const leftText = progress.total_size
|
||||
? `${downloaded} / ${total} (${percent}%)`
|
||||
? `${downloaded} / ${total} (${percent}%)${isPaused ? ' - Paused' : ''}`
|
||||
: '<span class="mr-info-accent-text">Connecting...</span>';
|
||||
let actionClass = canCancel ? 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm' : '';
|
||||
let actionText = canCancel ? 'Cancel' : '';
|
||||
let actionsHtml = '';
|
||||
if (canCancel && isAria2 && isPaused) {
|
||||
actionClass = 'resume-download-btn mr-btn mr-btn-primary mr-btn-sm';
|
||||
actionText = 'Resume';
|
||||
} else if (canCancel && isAria2 && status === 'downloading') {
|
||||
actionClass = 'pause-download-btn mr-btn mr-btn-secondary mr-btn-sm';
|
||||
actionText = 'Pause';
|
||||
}
|
||||
if (canCancel && isAria2 && (isPaused || status === 'downloading')) {
|
||||
const safeDownloadId = this.escapeHtml(String(downloadId));
|
||||
const toggleButton = isPaused
|
||||
? `<button class="resume-download-btn mr-btn mr-btn-primary mr-btn-sm" data-download-id="${safeDownloadId}">Resume</button>`
|
||||
: `<button class="pause-download-btn mr-btn mr-btn-secondary mr-btn-sm" data-download-id="${safeDownloadId}">Pause</button>`;
|
||||
actionsHtml = `${toggleButton}<button class="cancel-download-btn mr-btn mr-btn-danger mr-btn-sm" data-download-id="${safeDownloadId}">Cancel</button>`;
|
||||
}
|
||||
progressDiv.innerHTML = this.renderProgressWithAction({
|
||||
percent,
|
||||
leftText,
|
||||
rightText: progressMeta,
|
||||
actionClass: canCancel ? 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm' : '',
|
||||
actionText: canCancel ? 'Cancel' : '',
|
||||
actionClass,
|
||||
actionText,
|
||||
actionDataAttr: canCancel ? `data-download-id="${downloadId}"` : '',
|
||||
actionsHtml,
|
||||
contextMenuModel: this.getDownloadFolderContext(progress, snapshot)
|
||||
});
|
||||
this.attachDownloadCancelHandler(progressDiv, downloadId);
|
||||
this.attachDownloadActionHandlers(progressDiv, downloadId);
|
||||
} else if (status === 'cancelled') {
|
||||
progressDiv.innerHTML = this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning');
|
||||
} else if (status === 'cancelling') {
|
||||
@@ -896,13 +942,17 @@ export const resolveDownloadMethods = {
|
||||
const percent = Number(progress.progress);
|
||||
downloadBtn.disabled = true;
|
||||
downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary');
|
||||
const label = Number.isFinite(percent) && percent > 0 ? `Downloading ${Math.round(percent)}%` : 'Starting download...';
|
||||
const label = status === 'paused'
|
||||
? `Paused ${Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : ''}`.trim()
|
||||
: (Number.isFinite(percent) && percent > 0 ? `Downloading ${Math.round(percent)}%` : 'Starting download...');
|
||||
if (downloadBtn.classList.contains('search-download-btn')) {
|
||||
downloadBtn.innerHTML = getSvgIcon('download');
|
||||
downloadBtn.setAttribute('data-tooltip', label);
|
||||
downloadBtn.setAttribute('aria-label', label);
|
||||
} else {
|
||||
downloadBtn.textContent = Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...';
|
||||
downloadBtn.textContent = status === 'paused'
|
||||
? 'Paused'
|
||||
: (Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...');
|
||||
}
|
||||
} else if (status === 'cancelling') {
|
||||
downloadBtn.disabled = true;
|
||||
@@ -1255,7 +1305,8 @@ export const resolveDownloadMethods = {
|
||||
workflowTabId,
|
||||
workflowTabName,
|
||||
workflowTabAriaControls,
|
||||
workflowTabText
|
||||
workflowTabText,
|
||||
downloadBackend: data.download_backend || ''
|
||||
};
|
||||
|
||||
const snapshot = this.rememberDownloadUiState(
|
||||
@@ -1266,7 +1317,8 @@ export const resolveDownloadMethods = {
|
||||
progress: 0,
|
||||
filename,
|
||||
path: data.path || '',
|
||||
directory: data.directory || ''
|
||||
directory: data.directory || '',
|
||||
download_backend: data.download_backend || ''
|
||||
},
|
||||
{ isActive: true }
|
||||
);
|
||||
@@ -1359,6 +1411,59 @@ export const resolveDownloadMethods = {
|
||||
});
|
||||
},
|
||||
|
||||
setPendingDownloadStatus(info, status, durationMs = 8000) {
|
||||
if (!info) return;
|
||||
info.pendingDownloadStatus = status;
|
||||
info.pendingDownloadStatusStartedAt = Date.now();
|
||||
info.pendingDownloadStatusUntil = Date.now() + durationMs;
|
||||
},
|
||||
|
||||
clearPendingDownloadStatus(info) {
|
||||
if (!info) return;
|
||||
delete info.pendingDownloadStatus;
|
||||
delete info.pendingDownloadStatusStartedAt;
|
||||
delete info.pendingDownloadStatusUntil;
|
||||
},
|
||||
|
||||
applyPendingDownloadStatus(info, progress = {}) {
|
||||
if (!info?.pendingDownloadStatus) return progress;
|
||||
const desiredStatus = info.pendingDownloadStatus;
|
||||
const currentStatus = progress.status || '';
|
||||
const terminalStatuses = new Set(['completed', 'completed_checking', 'error', 'cancelled', 'cancelling']);
|
||||
|
||||
if (terminalStatuses.has(currentStatus)) {
|
||||
this.clearPendingDownloadStatus(info);
|
||||
return progress;
|
||||
}
|
||||
|
||||
if (
|
||||
currentStatus === desiredStatus &&
|
||||
Date.now() - Number(info.pendingDownloadStatusStartedAt || 0) > 1200
|
||||
) {
|
||||
this.clearPendingDownloadStatus(info);
|
||||
return progress;
|
||||
}
|
||||
|
||||
if (Date.now() > Number(info.pendingDownloadStatusUntil || 0)) {
|
||||
this.clearPendingDownloadStatus(info);
|
||||
return progress;
|
||||
}
|
||||
|
||||
const canHoldDesiredStatus = (
|
||||
desiredStatus === 'downloading' && currentStatus === 'paused'
|
||||
) || (
|
||||
desiredStatus === 'paused' && (currentStatus === 'downloading' || currentStatus === 'starting')
|
||||
);
|
||||
if (!canHoldDesiredStatus) return progress;
|
||||
|
||||
return {
|
||||
...progress,
|
||||
backend_status: currentStatus,
|
||||
status: desiredStatus,
|
||||
speed: desiredStatus === 'paused' ? 0 : progress.speed
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll download progress
|
||||
*/
|
||||
@@ -1367,18 +1472,23 @@ export const resolveDownloadMethods = {
|
||||
if (!info) return;
|
||||
|
||||
try {
|
||||
const progress = await this.fetchJson(`/model_resolver/progress/${downloadId}`, { silent: true }, 'Get download progress');
|
||||
let progress = await this.fetchJson(`/model_resolver/progress/${downloadId}`, { silent: true }, 'Get download progress');
|
||||
progress = this.applyPendingDownloadStatus(info, progress);
|
||||
const snapshot = this.rememberDownloadUiState(downloadId, info, progress, { isActive: true });
|
||||
const { progressDiv, downloadBtn } = this.resolveDownloadUiElements(info);
|
||||
const { missing, category } = info;
|
||||
const downloadFolderContext = this.getDownloadFolderContext(progress, info);
|
||||
|
||||
if (progress.status === 'downloading' || progress.status === 'starting') {
|
||||
if (progress.status === 'downloading' || progress.status === 'starting' || progress.status === 'paused') {
|
||||
this.renderDownloadSnapshot(downloadId, snapshot, { progressDiv, downloadBtn });
|
||||
this.updateQueuePanel?.();
|
||||
if (typeof this.requestQueuePanelUpdate === 'function') {
|
||||
this.requestQueuePanelUpdate();
|
||||
} else {
|
||||
this.updateQueuePanel?.();
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
setTimeout(() => this.pollDownloadProgress(downloadId), 1000);
|
||||
setTimeout(() => this.pollDownloadProgress(downloadId), progress.status === 'paused' ? 1500 : 1000);
|
||||
|
||||
} else if (progress.status === 'completed') {
|
||||
const alreadyExists = Boolean(progress.already_exists);
|
||||
@@ -1485,33 +1595,127 @@ export const resolveDownloadMethods = {
|
||||
/**
|
||||
* Cancel an active download
|
||||
*/
|
||||
async cancelDownload(downloadId) {
|
||||
try {
|
||||
await this.fetchJson(`/model_resolver/cancel/${downloadId}`, {
|
||||
method: 'POST'
|
||||
}, 'Cancel download');
|
||||
cancelDownload(downloadId) {
|
||||
const info = this.activeDownloads[downloadId];
|
||||
if (info) {
|
||||
this.clearPendingDownloadStatus(info);
|
||||
const snapshot = this.rememberDownloadUiState(
|
||||
downloadId,
|
||||
info,
|
||||
{ ...(info.lastProgress || {}), status: 'cancelling' },
|
||||
{
|
||||
status: 'cancelling',
|
||||
message: 'Cancelling download...',
|
||||
type: 'info',
|
||||
isActive: true
|
||||
}
|
||||
);
|
||||
const elements = this.resolveDownloadUiElements(info);
|
||||
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
||||
this.updateQueuePanel?.();
|
||||
}
|
||||
|
||||
const info = this.activeDownloads[downloadId];
|
||||
if (info) {
|
||||
this.fetchJson(`/model_resolver/cancel/${downloadId}`, {
|
||||
method: 'POST'
|
||||
}, 'Cancel download').catch((error) => {
|
||||
console.error('Model Resolver: Cancel error:', error);
|
||||
this.showNotification('Failed to cancel download', 'error');
|
||||
});
|
||||
},
|
||||
|
||||
pauseDownload(downloadId) {
|
||||
const info = this.activeDownloads[downloadId];
|
||||
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
|
||||
if (info) {
|
||||
this.setPendingDownloadStatus(info, 'paused');
|
||||
const snapshot = this.rememberDownloadUiState(
|
||||
downloadId,
|
||||
info,
|
||||
{ ...(info.lastProgress || {}), status: 'paused', speed: 0 },
|
||||
{
|
||||
status: 'paused',
|
||||
message: 'Download paused.',
|
||||
type: 'info',
|
||||
isActive: true
|
||||
}
|
||||
);
|
||||
const elements = this.resolveDownloadUiElements(info);
|
||||
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
||||
this.updateQueuePanel?.();
|
||||
}
|
||||
|
||||
this.fetchJson(`/model_resolver/pause/${downloadId}`, {
|
||||
method: 'POST'
|
||||
}, 'Pause download').then(() => {
|
||||
this.showNotification('Download paused', 'info');
|
||||
}).catch((error) => {
|
||||
console.error('Model Resolver: Pause error:', error);
|
||||
if (info && previousProgress) {
|
||||
this.clearPendingDownloadStatus(info);
|
||||
const snapshot = this.rememberDownloadUiState(
|
||||
downloadId,
|
||||
info,
|
||||
info.lastProgress || { status: 'cancelling', progress: 0 },
|
||||
previousProgress,
|
||||
{
|
||||
status: 'cancelling',
|
||||
message: 'Cancelling download...',
|
||||
type: 'info',
|
||||
status: previousProgress.status || 'downloading',
|
||||
type: 'error',
|
||||
isActive: true
|
||||
}
|
||||
);
|
||||
this.renderDownloadSnapshot(downloadId, snapshot);
|
||||
const elements = this.resolveDownloadUiElements(info);
|
||||
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
||||
this.updateQueuePanel?.();
|
||||
}
|
||||
this.showNotification(error.message || 'Failed to pause download', 'error');
|
||||
});
|
||||
},
|
||||
|
||||
} catch (error) {
|
||||
console.error('Model Resolver: Cancel error:', error);
|
||||
this.showNotification('Failed to cancel download', 'error');
|
||||
resumeDownload(downloadId) {
|
||||
const info = this.activeDownloads[downloadId];
|
||||
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
|
||||
if (info) {
|
||||
this.setPendingDownloadStatus(info, 'downloading');
|
||||
const snapshot = this.rememberDownloadUiState(
|
||||
downloadId,
|
||||
info,
|
||||
{ ...(info.lastProgress || {}), status: 'downloading' },
|
||||
{
|
||||
status: 'downloading',
|
||||
message: 'Download resumed.',
|
||||
type: 'info',
|
||||
isActive: true
|
||||
}
|
||||
);
|
||||
const elements = this.resolveDownloadUiElements(info);
|
||||
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
||||
this.updateQueuePanel?.();
|
||||
window.setTimeout(() => this.pollDownloadProgress(downloadId), 250);
|
||||
}
|
||||
|
||||
this.fetchJson(`/model_resolver/resume/${downloadId}`, {
|
||||
method: 'POST'
|
||||
}, 'Resume download').then(() => {
|
||||
this.showNotification('Download resumed', 'success');
|
||||
}).catch((error) => {
|
||||
console.error('Model Resolver: Resume error:', error);
|
||||
if (info && previousProgress) {
|
||||
this.clearPendingDownloadStatus(info);
|
||||
const snapshot = this.rememberDownloadUiState(
|
||||
downloadId,
|
||||
info,
|
||||
previousProgress,
|
||||
{
|
||||
status: previousProgress.status || 'paused',
|
||||
type: 'error',
|
||||
isActive: true
|
||||
}
|
||||
);
|
||||
const elements = this.resolveDownloadUiElements(info);
|
||||
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
||||
this.updateQueuePanel?.();
|
||||
}
|
||||
this.showNotification(error.message || 'Failed to resume download', 'error');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1926,6 +1926,7 @@ export const searchPanelMethods = {
|
||||
actionClass = '',
|
||||
actionText = '',
|
||||
actionDataAttr = '',
|
||||
actionsHtml = '',
|
||||
contextMenuModel = null,
|
||||
contextMenuTooltip = 'Right-click to open download folder'
|
||||
} = {}) {
|
||||
@@ -1935,9 +1936,9 @@ export const searchPanelMethods = {
|
||||
const containerClass = contextMenuAttrs
|
||||
? 'mr-progress-container mr-download-folder-context'
|
||||
: 'mr-progress-container';
|
||||
const actionHtml = actionClass || actionText
|
||||
const actionHtml = actionsHtml || (actionClass || actionText
|
||||
? `<button class="${actionClass}"${actionAttr}>${actionText}</button>`
|
||||
: '';
|
||||
: '');
|
||||
return `
|
||||
<div class="${containerClass}"${contextMenuAttrs}>
|
||||
<div class="mr-progress-row">
|
||||
|
||||
@@ -37,6 +37,24 @@ export const optionsMethods = {
|
||||
.map(option => `<option value="${option.value}" ${option.value === selected ? 'selected' : ''} title="${this.escapeHtml(option.tooltip)}">${this.escapeHtml(option.label)}</option>`)
|
||||
.join('');
|
||||
};
|
||||
const renderDownloadBackendOptions = (selectedValue) => {
|
||||
const selected = this.normalizeDownloadBackend(selectedValue);
|
||||
const options = [
|
||||
{
|
||||
value: 'python',
|
||||
label: 'Python (built-in)',
|
||||
tooltip: 'Uses Model Resolver built-in downloader. This remains the default and requires no external tools.'
|
||||
},
|
||||
{
|
||||
value: 'aria2',
|
||||
label: 'aria2 (experimental)',
|
||||
tooltip: 'Uses an external aria2c process for large downloads, pause/resume, and segmented transfers.'
|
||||
}
|
||||
];
|
||||
return options
|
||||
.map(option => `<option value="${option.value}" ${option.value === selected ? 'selected' : ''} title="${this.escapeHtml(option.tooltip)}">${this.escapeHtml(option.label)}</option>`)
|
||||
.join('');
|
||||
};
|
||||
const renderTemplatePlaceholderBadges = () => ['{base_model}', '{author}', '{first_tag}', '{model_name}', '{version_name}']
|
||||
.map(placeholder => `<span class="mr-options-placeholder-badge">${this.escapeHtml(placeholder)}</span>`)
|
||||
.join('');
|
||||
@@ -211,6 +229,33 @@ export const optionsMethods = {
|
||||
<h4 class="mr-options-section-title">Download Sources</h4>
|
||||
</div>
|
||||
<div class="mr-options-grid">
|
||||
<div class="mr-options-panel">
|
||||
<div class="mr-options-stack">
|
||||
<div class="mr-options-subsection-head">
|
||||
<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-copy">
|
||||
<span class="mr-options-label">Download backend</span>
|
||||
</div>
|
||||
<select id="mr-options-download-backend" class="mr-options-input">
|
||||
${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>
|
||||
<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-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}
|
||||
@@ -619,6 +664,10 @@ export const optionsMethods = {
|
||||
const autoFillBaseModelInput = this.contentElement.querySelector('#mr-options-auto-fill-base-model');
|
||||
const autoFillSubfolderInput = this.contentElement.querySelector('#mr-options-auto-fill-subfolder');
|
||||
const autoRefreshComfyModelsInput = this.contentElement.querySelector('#mr-options-auto-refresh-comfy-models');
|
||||
const downloadBackendInput = this.contentElement.querySelector('#mr-options-download-backend');
|
||||
const aria2cPathInput = this.contentElement.querySelector('#mr-options-aria2c-path');
|
||||
const aria2CheckBtn = this.contentElement.querySelector('#mr-options-aria2-check');
|
||||
const aria2StatusEl = this.contentElement.querySelector('#mr-options-aria2-status');
|
||||
const downloadPathModeInput = this.contentElement.querySelector('#mr-options-download-path-mode');
|
||||
const defaultRootSelectInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-default-root'));
|
||||
const templatePresetInputs = Array.from(this.contentElement.querySelectorAll('.mr-options-template-preset'));
|
||||
@@ -673,6 +722,8 @@ export const optionsMethods = {
|
||||
autoFillBaseModelInput,
|
||||
autoFillSubfolderInput,
|
||||
autoRefreshComfyModelsInput,
|
||||
downloadBackendInput,
|
||||
aria2cPathInput,
|
||||
downloadPathModeInput,
|
||||
...defaultRootSelectInputs,
|
||||
...templatePresetInputs,
|
||||
@@ -721,6 +772,40 @@ export const optionsMethods = {
|
||||
if (mode) statusEl.classList.add(mode);
|
||||
};
|
||||
|
||||
const setAria2Status = (text, mode = '') => {
|
||||
if (!aria2StatusEl) return;
|
||||
aria2StatusEl.textContent = text;
|
||||
aria2StatusEl.classList.remove('is-valid', 'is-invalid', 'is-pending');
|
||||
if (mode) aria2StatusEl.classList.add(mode);
|
||||
};
|
||||
|
||||
const checkAria2Status = async ({ useUnsavedPath = true } = {}) => {
|
||||
if (aria2CheckBtn) aria2CheckBtn.disabled = true;
|
||||
setAria2Status('Checking aria2...', 'is-pending');
|
||||
try {
|
||||
const data = await this.fetchJson('/model_resolver/aria2/status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
aria2c_path: useUnsavedPath
|
||||
? (aria2cPathInput?.value || '')
|
||||
: (tokens.aria2c_path || '')
|
||||
}),
|
||||
silent: true
|
||||
}, 'Check aria2');
|
||||
if (data?.available) {
|
||||
const path = data.resolved_path || 'aria2c from PATH';
|
||||
const runningText = data.running ? ' Daemon is running.' : ' Daemon will start on first aria2 download.';
|
||||
setAria2Status(`aria2 is available: ${path}.${runningText}`, 'is-valid');
|
||||
} else {
|
||||
setAria2Status(data?.error || 'aria2c was not found. Install aria2 or configure the aria2c path.', 'is-invalid');
|
||||
}
|
||||
} catch (error) {
|
||||
setAria2Status(error.message || 'aria2 check failed.', 'is-invalid');
|
||||
} finally {
|
||||
if (aria2CheckBtn) aria2CheckBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => {
|
||||
const value = (input?.value || '').trim();
|
||||
if (!value) {
|
||||
@@ -1494,6 +1579,16 @@ export const optionsMethods = {
|
||||
});
|
||||
}
|
||||
|
||||
if (aria2CheckBtn) {
|
||||
aria2CheckBtn.addEventListener('click', () => {
|
||||
checkAria2Status({ useUnsavedPath: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.download_backend === 'aria2') {
|
||||
checkAria2Status({ useUnsavedPath: false });
|
||||
}
|
||||
|
||||
if (civitaiSessionCheckBtn) {
|
||||
civitaiSessionCheckBtn.addEventListener('click', () => {
|
||||
checkCredential({
|
||||
@@ -1576,6 +1671,7 @@ export const optionsMethods = {
|
||||
);
|
||||
});
|
||||
const baseModelPathMappings = collectBaseModelPathMappings();
|
||||
const downloadBackend = this.normalizeDownloadBackend(downloadBackendInput?.value || tokens.download_backend);
|
||||
const downloadPathMode = this.normalizeDownloadPathMode(downloadPathModeInput?.value || tokens.download_path_mode);
|
||||
const defaultRootSettings = {};
|
||||
defaultRootSelectInputs.forEach((select) => {
|
||||
@@ -1598,6 +1694,8 @@ export const optionsMethods = {
|
||||
auto_fill_base_model: Boolean(autoFillBaseModelInput?.checked),
|
||||
auto_fill_subfolder: Boolean(autoFillSubfolderInput?.checked),
|
||||
auto_refresh_comfy_models_after_apply: Boolean(autoRefreshComfyModelsInput?.checked),
|
||||
download_backend: downloadBackend,
|
||||
aria2c_path: aria2cPathInput?.value?.trim() || '',
|
||||
download_path_mode: downloadPathMode,
|
||||
download_path_templates: downloadPathTemplates,
|
||||
base_model_path_mappings: baseModelPathMappings,
|
||||
@@ -1623,6 +1721,8 @@ export const optionsMethods = {
|
||||
localStorage.setItem('ModelResolver.autoFillBaseModel', newSettings.auto_fill_base_model ? 'true' : 'false');
|
||||
localStorage.setItem('ModelResolver.autoFillSubfolder', newSettings.auto_fill_subfolder ? 'true' : 'false');
|
||||
localStorage.setItem('ModelResolver.autoRefreshComfyModelsAfterApply', newSettings.auto_refresh_comfy_models_after_apply ? 'true' : 'false');
|
||||
localStorage.setItem('ModelResolver.downloadBackend', newSettings.download_backend);
|
||||
localStorage.setItem('ModelResolver.aria2cPath', newSettings.aria2c_path);
|
||||
localStorage.setItem('ModelResolver.downloadPathMode', newSettings.download_path_mode);
|
||||
localStorage.setItem('ModelResolver.downloadPathTemplates', JSON.stringify(newSettings.download_path_templates));
|
||||
localStorage.setItem('ModelResolver.baseModelPathMappings', JSON.stringify(newSettings.base_model_path_mappings));
|
||||
@@ -1666,6 +1766,11 @@ export const optionsMethods = {
|
||||
|
||||
},
|
||||
|
||||
normalizeDownloadBackend(value = '') {
|
||||
const backend = String(value || '').trim().toLowerCase();
|
||||
return backend === 'aria2' ? 'aria2' : 'python';
|
||||
},
|
||||
|
||||
getStoredTokens() {
|
||||
const civitaiCandidateLimitRaw = parseInt(localStorage.getItem('ModelResolver.civitaiCandidateLimit') || '5', 10);
|
||||
const civitai_candidate_limit = Number.isFinite(civitaiCandidateLimitRaw)
|
||||
@@ -1700,6 +1805,8 @@ export const optionsMethods = {
|
||||
backend_log_level: storedBackendLogLevel || 'DEBUG',
|
||||
civitai_candidate_limit,
|
||||
search_source_enabled,
|
||||
download_backend: this.normalizeDownloadBackend(localStorage.getItem('ModelResolver.downloadBackend') || 'python'),
|
||||
aria2c_path: localStorage.getItem('ModelResolver.aria2cPath') || '',
|
||||
download_path_mode: this.getDownloadPathMode(),
|
||||
download_path_templates: this.getDownloadPathTemplates(),
|
||||
base_model_path_mappings: this.getBaseModelPathMappings(),
|
||||
@@ -1755,6 +1862,10 @@ export const optionsMethods = {
|
||||
localStorage.setItem('ModelResolver.autoFillSubfolder', data.auto_fill_subfolder ? 'true' : 'false');
|
||||
if (data.auto_refresh_comfy_models_after_apply !== undefined)
|
||||
localStorage.setItem('ModelResolver.autoRefreshComfyModelsAfterApply', data.auto_refresh_comfy_models_after_apply ? 'true' : 'false');
|
||||
if (data.download_backend !== undefined)
|
||||
localStorage.setItem('ModelResolver.downloadBackend', this.normalizeDownloadBackend(data.download_backend));
|
||||
if (data.aria2c_path !== undefined)
|
||||
localStorage.setItem('ModelResolver.aria2cPath', String(data.aria2c_path || ''));
|
||||
if (data.download_path_mode !== undefined)
|
||||
localStorage.setItem('ModelResolver.downloadPathMode', this.normalizeDownloadPathMode(data.download_path_mode));
|
||||
if (data.download_path_templates !== undefined)
|
||||
|
||||
Reference in New Issue
Block a user