Files
Medios-Macina/scripts/hydrus_web_gui.py
T
2026-04-29 17:15:56 -07:00

345 lines
12 KiB
Python

#!/usr/bin/env python3
"""Install and manage the Hydrus web GUI companion app.
This helper keeps the Medios bootstrap as the single user-facing installer entry
point while isolating the Node/npm-specific logic required by
`api-HydrusNetwork`.
"""
from __future__ import annotations
import argparse
import os
import platform
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
DEFAULT_REPO_URL = "https://code.glowers.club/goyimnose/api-HydrusNetwork.git"
DEFAULT_DEST_NAME = "api-HydrusNetwork"
DEFAULT_SERVICE_NAME = "hydrus-web-gui"
DEFAULT_PORT = 4173
def run(
cmd: list[str],
*,
cwd: Path | None = None,
check: bool = True,
capture_output: bool = False,
debug: bool = False,
) -> subprocess.CompletedProcess:
if debug:
location = f" (cwd={cwd})" if cwd else ""
print(f"> {' '.join(cmd)}{location}")
return subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
check=check,
text=capture_output,
capture_output=capture_output,
)
def find_executable(*names: str) -> str | None:
for name in names:
found = shutil.which(name)
if found:
return found
return None
def detect_npm() -> str | None:
if os.name == "nt":
return find_executable("npm.cmd", "npm")
return find_executable("npm")
def parse_node_major_version(node_exe: str, debug: bool = False) -> int | None:
try:
result = run([node_exe, "--version"], capture_output=True, debug=debug)
version_text = (result.stdout or "").strip()
if version_text.startswith("v"):
version_text = version_text[1:]
major = version_text.split(".", 1)[0]
return int(major)
except Exception:
return None
def with_privilege(cmd: list[str]) -> list[str]:
if os.name == "nt":
return cmd
if hasattr(os, "geteuid") and os.geteuid() == 0:
return cmd
sudo = shutil.which("sudo")
if sudo:
return [sudo, *cmd]
return cmd
def ensure_node_runtime(debug: bool = False) -> tuple[str, str]:
node_exe = find_executable("node")
npm_exe = detect_npm()
major = parse_node_major_version(node_exe, debug=debug) if node_exe else None
if node_exe and npm_exe and major is not None and major >= 20:
return node_exe, npm_exe
system = platform.system().lower()
print("Node.js 20+ and npm are required for the Hydrus web GUI. Attempting installation...")
if system == "windows":
winget = find_executable("winget")
if not winget:
raise RuntimeError(
"Node.js 20+ is required. Install it manually or ensure winget is available."
)
run(
[
winget,
"install",
"--id",
"OpenJS.NodeJS.LTS",
"-e",
"--accept-package-agreements",
"--accept-source-agreements",
],
debug=debug,
)
elif system == "linux":
if find_executable("apt"):
run(with_privilege(["apt", "update"]), debug=debug)
run(with_privilege(["apt", "install", "-y", "nodejs", "npm"]), debug=debug)
elif find_executable("dnf"):
run(with_privilege(["dnf", "install", "-y", "nodejs", "npm"]), debug=debug)
elif find_executable("pacman"):
run(with_privilege(["pacman", "-Sy", "--noconfirm", "nodejs", "npm"]), debug=debug)
else:
raise RuntimeError(
"Node.js 20+ is required. Install nodejs/npm manually for this distribution."
)
else:
raise RuntimeError("Automatic Hydrus web GUI setup is currently supported on Windows and Linux.")
node_exe = find_executable("node")
npm_exe = detect_npm()
major = parse_node_major_version(node_exe, debug=debug) if node_exe else None
if not node_exe or not npm_exe or major is None or major < 20:
raise RuntimeError(
"Node.js installation completed, but Node.js 20+ with npm is still not available on PATH."
)
return node_exe, npm_exe
def clone_or_update_repo(repo_url: str, dest: Path, force: bool = False, debug: bool = False) -> None:
git = find_executable("git")
if not git:
raise RuntimeError("git is required to install the Hydrus web GUI.")
if dest.exists() and force:
shutil.rmtree(dest)
if dest.exists():
if (dest / ".git").exists():
run([git, "-C", str(dest), "pull", "--ff-only"], debug=debug)
return
if any(dest.iterdir()):
raise RuntimeError(
f"Destination already exists and is not a git repository: {dest}. Use --force to replace it."
)
dest.parent.mkdir(parents=True, exist_ok=True)
run([git, "clone", "--depth", "1", repo_url, str(dest)], debug=debug)
def install_web_gui_dependencies(app_root: Path, npm_exe: str, debug: bool = False) -> None:
run([npm_exe, "install"], cwd=app_root, debug=debug)
run([npm_exe, "run", "build"], cwd=app_root, debug=debug)
def write_service_launcher(app_root: Path, npm_exe: str, port: int) -> Path:
if os.name == "nt":
launcher = app_root / "run-hydrus-web-gui.cmd"
launcher.write_text(
"@echo off\n"
"setlocal\n"
"cd /d \"%~dp0\"\n"
f"call \"{npm_exe}\" run build\n"
"if errorlevel 1 exit /b %errorlevel%\n"
f"call \"{npm_exe}\" run preview -- --host 0.0.0.0 --port {port}\n",
encoding="utf-8",
)
return launcher
launcher = app_root / "run-hydrus-web-gui.sh"
launcher.write_text(
"#!/usr/bin/env bash\n"
"set -e\n"
"cd \"$(dirname \"$0\")\"\n"
f"\"{npm_exe}\" run build\n"
f"exec \"{npm_exe}\" run preview -- --host 0.0.0.0 --port {port}\n",
encoding="utf-8",
)
launcher.chmod(launcher.stat().st_mode | 0o111)
return launcher
def install_service_windows(service_name: str, launcher_path: Path, debug: bool = False) -> None:
schtasks = find_executable("schtasks")
if not schtasks:
raise RuntimeError("schtasks is required to register the Hydrus web GUI on Windows.")
tr_command = f'cmd.exe /c ""{launcher_path}""'
run(
[
schtasks,
"/Create",
"/SC",
"ONLOGON",
"/TN",
service_name,
"/TR",
tr_command,
"/RL",
"LIMITED",
"/F",
],
debug=debug,
)
def install_service_linux(service_name: str, app_root: Path, launcher_path: Path, debug: bool = False) -> None:
systemctl = find_executable("systemctl")
if not systemctl:
raise RuntimeError("systemctl is required to register the Hydrus web GUI on Linux.")
if hasattr(os, "geteuid") and os.geteuid() == 0:
unit_dir = Path("/etc/systemd/system")
unit_name = f"{service_name}.service"
wanted_by = "multi-user.target"
systemctl_cmd = [systemctl]
service_user = os.environ.get("SUDO_USER") or os.environ.get("USER")
else:
unit_dir = Path.home() / ".config" / "systemd" / "user"
unit_name = f"{service_name}.service"
wanted_by = "default.target"
systemctl_cmd = [systemctl, "--user"]
service_user = None
unit_dir.mkdir(parents=True, exist_ok=True)
unit_file = unit_dir / unit_name
exec_start = f"/bin/bash -lc {shlex.quote(str(launcher_path))}"
unit_lines = [
"[Unit]",
"Description=Hydrus Web GUI",
"After=network-online.target",
"Wants=network-online.target",
"",
"[Service]",
"Type=simple",
f"WorkingDirectory={app_root}",
f"ExecStart={exec_start}",
"Restart=on-failure",
"Environment=NODE_ENV=production",
]
if service_user and hasattr(os, "geteuid") and os.geteuid() == 0:
unit_lines.append(f"User={service_user}")
unit_lines.append(f"Group={service_user}")
unit_lines.extend([
"",
"[Install]",
f"WantedBy={wanted_by}",
"",
])
unit_file.write_text("\n".join(unit_lines), encoding="utf-8")
run([*systemctl_cmd, "daemon-reload"], debug=debug)
run([*systemctl_cmd, "enable", "--now", unit_name], debug=debug)
def install_service(service_name: str, app_root: Path, launcher_path: Path, debug: bool = False) -> None:
system = platform.system().lower()
if system == "windows":
install_service_windows(service_name, launcher_path, debug=debug)
return
if system == "linux":
install_service_linux(service_name, app_root, launcher_path, debug=debug)
return
raise RuntimeError("Service installation for the Hydrus web GUI is only supported on Windows and Linux.")
def setup_mpv_handler(app_root: Path, npm_exe: str, debug: bool = False) -> None:
system = platform.system().lower()
if system not in {"windows", "linux"}:
print("Skipping mpv-handler setup: this helper is only automated on Windows and Linux.")
return
run([npm_exe, "run", "setup:mpv-handler"], cwd=app_root, debug=debug)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Install the Hydrus web GUI companion app")
parser.add_argument("--root", default=".", help="Root directory that will contain the checkout")
parser.add_argument("--dest-name", default=DEFAULT_DEST_NAME, help="Folder name for the checkout")
parser.add_argument("--repo", default=DEFAULT_REPO_URL, help="Repository URL for the Hydrus web GUI")
parser.add_argument("--force", action="store_true", help="Replace an existing non-empty destination")
parser.add_argument(
"--install-service",
action="store_true",
help="Register the web GUI to start automatically using Task Scheduler or systemd",
)
parser.add_argument(
"--service-name",
default=DEFAULT_SERVICE_NAME,
help=f"Service name to register (default: {DEFAULT_SERVICE_NAME})",
)
parser.add_argument(
"--setup-mpv-handler",
action="store_true",
help="Run the web GUI's mpv-handler setup helper after installation",
)
parser.add_argument(
"--port",
type=int,
default=DEFAULT_PORT,
help=f"Preferred preview port for the generated service launcher (default: {DEFAULT_PORT})",
)
parser.add_argument("--debug", action="store_true", help="Show installer debug output")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
root = Path(os.path.expandvars(os.path.expanduser(args.root))).resolve()
dest = root / os.path.expandvars(args.dest_name)
try:
_, npm_exe = ensure_node_runtime(debug=args.debug)
clone_or_update_repo(args.repo, dest, force=args.force, debug=args.debug)
install_web_gui_dependencies(dest, npm_exe, debug=args.debug)
launcher_path = write_service_launcher(dest, npm_exe, args.port)
if args.setup_mpv_handler:
setup_mpv_handler(dest, npm_exe, debug=args.debug)
if args.install_service:
install_service(args.service_name, dest, launcher_path, debug=args.debug)
print(f"Hydrus web GUI ready at: {dest}")
print(f"Launcher: {launcher_path}")
print(f"URL: http://localhost:{args.port}")
return 0
except subprocess.CalledProcessError as exc:
print(f"Hydrus web GUI install failed: {exc}", file=sys.stderr)
return int(exc.returncode or 1)
except Exception as exc:
print(f"Hydrus web GUI install error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())